import Foundation import SwiftGitX import Testing import libgit2 @testable import Kanban /// **Repository hygiene** (06-history-undo.md ▸ Repository hygiene) — the two behaviours that keep a /// git board's `.git` sane without ever rewriting anything: the `.gitignore` seeded once at init, and /// the periodic repack that packs loose objects and touches nothing else. /// /// Every repository here is a **real** one, made by the app's own add-git through the bundled /// libgit2, and every assertion is read off the filesystem or out of the object database rather than /// through a mock: a housekeeping bug corrupts repositories, so the only tests worth having are the /// ones a corrupt repository would fail. /// /// Nothing here shells out to `git`. // MARK: - Fixtures /// A board with one lane and one card — 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 commit made the way an external writer makes one — SwiftGitX directly, so the objects under /// test are ordinary git objects and not something the app's own path produced. 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) } /// Enough commits that the repository has a non-trivial pile of loose objects to pack. private func churn(_ fixture: WriterFixture, commits: Int) throws { for step in 1...commits { try fixture.item( "\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "\(step)024", title: "Second, take \(step)") ) try fixture.file("notes.txt", Data(String(repeating: "\(step)", count: 64).utf8)) try commitEverything(at: fixture.root, message: "Change \(step)") } } // MARK: - Filesystem instruments /// One entry under a subtree: path, bytes (nil for directories), and mtime — `InertGitTests`' /// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion /// that nothing opened the file for writing at all. private struct SubtreeEntry: Equatable, CustomStringConvertible { let path: String let data: Data? let modified: Date var description: String { "\(path) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))" } } /// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `skip` prunes /// whole branches — how the working tree is snapshotted without `.git`, and `.git` without /// `objects/`. private func snapshot( _ root: URL, _ subtree: String, skipping skip: Set = [] ) throws -> [SubtreeEntry] { let base = subtree.isEmpty ? root : root.appendingPathComponent(subtree, 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 head = relative.split(separator: "/").first.map(String.init) ?? relative if skip.contains(head) { walker.skipDescendants() continue } 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: - Object-database instruments /// **Every object the repository can answer for**, loose and packed alike, by oid. /// /// This is the "nothing was forgotten" instrument, and `git_odb_foreach` is the only honest way to /// ask it: it enumerates the whole database through every backend, so a repack that packed some /// objects and dropped others shows up as a set that shrank. Read through a repository opened /// *after* the pass, so the answer comes from what is on disk rather than from a cached view of what /// used to be. private func everyObject(at boardRoot: URL) -> Set { var repository: OpaquePointer? guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return [] } defer { git_repository_free(repository) } var database: OpaquePointer? guard git_repository_odb(&database, repository) == 0, let database else { return [] } defer { git_odb_free(database) } var found = Set() withUnsafeMutablePointer(to: &found) { payload in _ = git_odb_foreach(database, { oid, payload in guard let oid, let payload else { return 0 } var value = oid.pointee var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1) git_oid_fmt(&buffer, &value) payload.assumingMemoryBound(to: Set.self).pointee.insert(String(cString: buffer)) return 0 }, payload) } return found } /// The oid git would give a file's bytes as a blob — `git hash-object`, computed rather than looked /// up, so a test can ask "is *this content* still in the database" without walking a tree to find it. private func blobOID(of data: Data) -> String? { var oid = git_oid() let status = data.withUnsafeBytes { buffer in git_odb_hash(&oid, buffer.baseAddress, buffer.count, GIT_OBJECT_BLOB) } guard status == 0 else { return nil } var value = oid var text = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1) git_oid_fmt(&text, &value) return String(cString: text) } /// HEAD's first-parent ancestry, oldest last — the history walk, as oids and subjects, so "identical" /// means identical rather than "HEAD still resolves". private func historyWalk(at boardRoot: URL) throws -> [String] { let repository = try Repository.open(at: boardRoot) guard var commit = try repository.HEAD.target as? Commit else { return [] } var trail = ["\(commit.id.hex) \(commit.summary)"] while let parent = try commit.parents.first { commit = parent trail.append("\(commit.id.hex) \(commit.summary)") } return trail } // MARK: - .gitignore seeding @MainActor @Suite("Repository hygiene ▸ the seeded .gitignore") struct GitignoreSeedTests { @Test("Add-git seeds a .gitignore containing .DS_Store, inside 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 file, and the whole of the file: one line, because one line is the rule // (06 ▸ Repository hygiene: "a minimal `.gitignore` (`.DS_Store`)"). #expect(try fixture.data(".gitignore") == Data(".DS_Store\n".utf8)) // **In "Initial board state", not after it.** Seeding after the commit would put the app's // own file into the board's first *foreign* commit; seeding before makes it part of the // board's beginning, which is what it is. let head = try #require(GitRepository.headCommit(at: fixture.root)) #expect(head.subject == "Initial board state") #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore")) } @Test("A .DS_Store already under the board never enters history at all") func theSeedTakesEffectFromTheFirstCommit() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } // What the Finder leaves behind: one per folder the user has looked at. try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42])) try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43])) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) // Not "removed from history later" — never in it. The app has no history-rewriting operation // and never will (06 ▸ Deleting never forgets), so the only moment this could be got right // is the first one. let tracked = GitRepository.trackedPaths(at: fixture.root) #expect(!tracked.contains { $0.hasSuffix(".DS_Store") }) #expect(fixture.exists(".DS_Store"), "and the file itself is left exactly where it is") } @Test("A board that already has a .gitignore is left byte-for-byte alone") func anExistingIgnoreFileIsUntouched() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let mine = Data("# mine\nbuild/\n*.tmp\n".utf8) try fixture.file(".gitignore", mine) let before = try snapshot(fixture.root, ".gitignore") let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) // Not merged, not appended to, not reordered — and not even opened for writing, which is // what the mtime says (06: "the app never edits an existing one"). #expect(try fixture.data(".gitignore") == mine) #expect(try snapshot(fixture.root, ".gitignore") == before) #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore")) } @Test("The app never manages the file afterwards — commits and housekeeping leave it alone") func theFileIsTheUsersFromThenOn() 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 user edits it — including deleting the line the app seeded, which is their business. let theirs = Data("*.log\n".utf8) try fixture.file(".gitignore", theirs) let before = try snapshot(fixture.root, ".gitignore") try churn(fixture, commits: 3) _ = GitHousekeeping.run(at: fixture.root, threshold: 1) await git.committer?.flushNow() #expect(try fixture.data(".gitignore") == theirs, "nothing in the app re-seeds it") #expect(try snapshot(fixture.root, ".gitignore") == before) } @Test("Adoption seeds nothing — an adopted repository is somebody else's init") func adoptionSeedsNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in. _ = try Repository.create(at: fixture.root) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.mode == .git) #expect(!fixture.exists(".gitignore"), "the seed belongs to the app's own init and nowhere else") } @Test("A repo-nested board gets no seed, because it gets no app-managed git") func repoNestedBoardsGetNothing() async throws { let outer = try WriterFixture() defer { outer.tearDown() } try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) 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(".gitignore").path)) #expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(".gitignore").path)) } } // MARK: - The housekeeping pass @MainActor @Suite("Repository hygiene ▸ periodic housekeeping") struct GitHousekeepingTests { @Test("A pass repacks loose objects and alters no commit, no ref, and no reachable content") func repackingChangesNothing() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 4) let looseBefore = GitHousekeeping.looseObjectCount(at: fixture.root) #expect(looseBefore > 0, "the fixture has to have something to pack") let objectsBefore = everyObject(at: fixture.root) let walkBefore = try historyWalk(at: fixture.root) let trackedBefore = GitRepository.trackedPaths(at: fixture.root) let refsBefore = try snapshot(fixture.root, ".git/refs") let headFileBefore = try snapshot(fixture.root, ".git/HEAD") let workingTreeBefore = try snapshot(fixture.root, "", skipping: [".git"]) let outcome = GitHousekeeping.run(at: fixture.root, threshold: 1) guard case let .repacked(repack) = outcome else { Issue.record("expected a repack, got \(outcome)") return } // It did something… #expect(repack.looseBefore == looseBefore) #expect(repack.packedAway > 0) #expect(repack.packedAway == repack.inserted, "every inserted object was proved and removed") #expect(GitHousekeeping.looseObjectCount(at: fixture.root) < looseBefore) // …and it forgot nothing. Loose objects moved into a pack are the *same* objects: the whole // database answers for exactly the set it answered for before (06 ▸ Repository hygiene: // "it rewrites nothing"). #expect(everyObject(at: fixture.root) == objectsBefore) // No commit, no ref, no reachable content. #expect(try historyWalk(at: fixture.root) == walkBefore) #expect(GitRepository.trackedPaths(at: fixture.root) == trackedBefore) #expect(try snapshot(fixture.root, ".git/refs") == refsBefore) #expect(try snapshot(fixture.root, ".git/HEAD") == headFileBefore) // And the working tree never came into it — housekeeping is a fact about `.git/objects` and // nothing else. #expect(try snapshot(fixture.root, "", skipping: [".git"]) == workingTreeBefore) } @Test("Every object is still readable after a pass, one oid at a time") func everyObjectSurvives() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) let before = everyObject(at: fixture.root) #expect(!before.isEmpty) _ = GitHousekeeping.run(at: fixture.root, threshold: 1) // The set comparison above is the same claim in aggregate; this is it per object, which is // the shape a corruption bug would actually take — one blob that went nowhere. let after = everyObject(at: fixture.root) for oid in before { #expect(after.contains(oid), "object \(oid) stopped being readable") } } @Test("Two passes in a row are stable — the second finds nothing left to do") func aSecondPassIsANoOp() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) _ = GitHousekeeping.run(at: fixture.root, threshold: 1) let objects = everyObject(at: fixture.root) let loose = GitHousekeeping.looseObjectCount(at: fixture.root) // Nothing re-packs what is already packed, so the second pass reads below any threshold the // first one left it under — and a repository that keeps being repacked would be growth, not // hygiene. #expect(GitHousekeeping.run(at: fixture.root, threshold: max(1, loose + 1)) == .belowThreshold(loose: loose)) #expect(everyObject(at: fixture.root) == objects) } @Test("Below the threshold, the pass reads the count and does nothing at all") func belowThresholdTouchesNothing() 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 before = try snapshot(fixture.root, ".git") let loose = GitHousekeeping.looseObjectCount(at: fixture.root) #expect(loose > 0) #expect(GitHousekeeping.run(at: fixture.root, threshold: loose + 1) == .belowThreshold(loose: loose)) #expect(try snapshot(fixture.root, ".git") == before, "a gate that closed wrote nothing") } @Test("The default threshold is git's own gc.auto, so an ordinary board is never repacked at open") func theDefaultThresholdIsGitsOwn() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) #expect(GitHousekeeping.defaultLooseObjectThreshold == 6700) let loose = GitHousekeeping.looseObjectCount(at: fixture.root) #expect(GitHousekeeping.run(at: fixture.root) == .belowThreshold(loose: loose)) } @Test("A paused repository is skipped in silence, and stays untouched") func aPausedRepositoryIsSkipped() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) // The marker file libgit2's own `git_repository_state` reads — an outside-the-app merge, in // progress. The committer holds for this (06 ▸ Rules ▸ Abnormal repo states); optional work // simply does not happen. let head = try #require(GitRepository.headCommit(at: fixture.root)) try fixture.file(".git/MERGE_HEAD", Data("\(head.oid)\n".utf8)) let before = try snapshot(fixture.root, ".git") #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held)) #expect(try snapshot(fixture.root, ".git") == before) } @Test("A held index.lock is skipped in silence, and stays untouched") func aHeldLockIsSkipped() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) // Another writer, mid-operation. The lock is never removed, whatever the app is doing — // it isn't the app's (06 ▸ Interaction with external writers). try fixture.file(".git/index.lock", Data()) let before = try snapshot(fixture.root, ".git") #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.indexLocked)) #expect(try snapshot(fixture.root, ".git") == before) #expect(fixture.exists(".git/index.lock"), "and the lock is still somebody else's") } @Test("Deleting a card leaves every prior commit touching its folder fully intact") func deletingNeverForgets() 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 cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md" let birth = try #require(GitRepository.headCommit(at: fixture.root)) let content = try fixture.data(cardPath) let contentOID = try #require(blobOID(of: content)) #expect(everyObject(at: fixture.root).contains(contentOID), "the card's bytes are in the repository") // A delete, in both of its shapes: into `.trash/`, then gone for good. try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)") try commitEverything(at: fixture.root, message: "Delete card 'First'") try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card1)")) try commitEverything(at: fixture.root, message: "Permanently delete card 'First'") // Off the live board, and out of the working tree… #expect(!GitRepository.trackedPaths(at: fixture.root).contains(cardPath)) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) // …and every version of its content still reachable in the repository, with the commit that // introduced it exactly where it was. "Deleting never forgets" (06 ▸ Repository hygiene) is // stated design, and there is no code path in the app that could take it back: nothing // rewrites history, and housekeeping packs rather than prunes. _ = GitHousekeeping.run(at: fixture.root, threshold: 1) let objects = everyObject(at: fixture.root) #expect(objects.contains(contentOID), "the deleted card's bytes are still in the object database") #expect(objects.contains(birth.oid), "and so is the commit that introduced them") #expect(try historyWalk(at: fixture.root).last == "\(birth.oid) \(birth.subject)") } @Test("A board with no repository is skipped, and no repository appears") func aBoardWithNoRepositoryIsSkipped() throws { let fixture = try makeBoard() defer { fixture.tearDown() } #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.noRepository)) #expect(!fixture.exists(".git")) } } // MARK: - The scheduler @MainActor @Suite("Repository hygiene ▸ when housekeeping runs") struct GitHousekeeperSchedulingTests { @Test("A git-mode store composes a housekeeper; a mode-none one composes none") func compositionFollowsMode() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(plain.housekeeper == nil, "no repository, nothing to maintain") #expect(await plain.addGit()) #expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board") let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(reopened.housekeeper != nil) } @Test("The free tier has no housekeeper anywhere, because it has no git state at all") func theFreeTierMaintainsNothing() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await seed.addGit()) // Structural, not conditional: with no `HistoryStore` there is no housekeeper to disable and // no code path that could reach one (12-editions.md ▸ The free tier and `.git`). #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) } @Test("A commit in flight defers the pass entirely — it is never retried") func aCommitInFlightDefersThePass() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) try churn(fixture, commits: 3) let housekeeper = try #require(git.housekeeper) housekeeper.threshold = 1 housekeeper.isCommitInFlight = { true } let before = try snapshot(fixture.root, ".git") await housekeeper.runNow() #expect(housekeeper.lastOutcome == nil, "it did not run, and recorded no verdict") #expect(try snapshot(fixture.root, ".git") == before) // And with the engine quiet it runs — the same pass, one board-open later. housekeeper.isCommitInFlight = { false } await housekeeper.runNow() guard case .repacked = housekeeper.lastOutcome else { Issue.record("expected a repack once the committer was quiet, got \(String(describing: housekeeper.lastOutcome))") return } } @Test("Activating auto-commit arms the pass, and teardown cancels it") func activationArmsAndTeardownCancels() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await seed.addGit()) try churn(fixture, commits: 3) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let housekeeper = try #require(git.housekeeper) housekeeper.threshold = 1 housekeeper.delay = .milliseconds(20) git.committer?.debounceInterval = .seconds(60) git.activateAutoCommit { _ in } try await Task.sleep(for: .milliseconds(400)) guard case .repacked = housekeeper.lastOutcome else { Issue.record("board open arms one pass, got \(String(describing: housekeeper.lastOutcome))") return } // Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store // that has gone. let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let secondKeeper = try #require(second.housekeeper) secondKeeper.threshold = 1 secondKeeper.delay = .milliseconds(200) second.activateAutoCommit { _ in } second.stopAutoCommit() try await Task.sleep(for: .milliseconds(500)) #expect(secondKeeper.lastOutcome == nil) } @Test("The wired gate is the committer's own in-flight flag") func theGateIsTheCommittersOwnFlag() 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 housekeeper = try #require(git.housekeeper) let committer = try #require(git.committer) #expect(committer.isCommitInFlight == false, "a quiet engine") #expect(housekeeper.isCommitInFlight?() == false, "and the housekeeper reads it, not a copy") } }