Implement repository hygiene
Add-git seeds a minimal .gitignore (.DS_Store) before the initial stage — the seed rides "Initial board state" and .DS_Store never enters history; an existing .gitignore (or a directory wearing the name) is left alone forever, adoption and repo-nested seed nothing. GitHousekeeping is the periodic loose-object repack: filesystem enumeration of objects/<2hex>/<38hex> (never git_odb_foreach, which would rewrite the whole database into a fresh pack each pass), git_packbuilder_insert one oid at a time, additive pack write — and deletion only after each oid is re-verified against the written pack opened as a standalone one-pack odb with no loose backend. Any failure returns before deleting; the worst case is a stray pack. Nothing prunes, expires, or consolidates — existing packs accumulate, recorded as the accepted cost of never rewriting storage the app didn't write. GitHousekeeper schedules it: git's own 6700 threshold, 8s after session activation (outlasting the launch catch-up), background priority, skipped under pause states, held locks, or an in-flight commit, never retried — the next open tries again. Free tier composes none of it. 20 new tests: full-odb equality, per-oid survival, identical walks, byte+mtime-identical refs/HEAD/working tree, whole-.git identity on every declined pass, and deleting-never-forgets. 2394 tests / 412 suites green; InertGitTests untouched. Closes pro-m1-git-undo. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
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<String> = []
|
||||
) 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<String> {
|
||||
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<String>()
|
||||
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<String>.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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user