import Foundation import libgit2 import os // MARK: - Outcomes /// **Why a housekeeping pass did nothing** — every one of these is a shrug, never a failure. /// /// "Safe libgit2 housekeeping (repacking loose objects) may run periodically, but it rewrites /// nothing" (06-history-undo.md ▸ Repository hygiene). Nothing here reaches a user, nothing here is /// retried, and nothing here is worth a banner: maintenance that does not happen costs the board a /// slightly larger `.git` and nothing else, so every uncertainty resolves to *not now*. public enum GitHousekeepingSkip: String, Sendable, Equatable, CaseIterable { /// No repository at the board root, or libgit2 could not open the one that is there. case noRepository /// The repository is in a state the app does not write in (`GitRepositoryPause`) — a merge, a /// rebase, a detached HEAD. The commit engine holds for these; so does this, for the simpler /// reason that optional work has no business running beside somebody else's operation. case held /// `index.lock` is held right now — another writer is mid-operation. case indexLocked /// Every loose object libgit2 refused to read, so there was nothing to pack. A pass that inserts /// nothing writes no pack and deletes nothing. case nothingToPack /// The pack could not be written, or the written pack could not be re-opened for verification. /// **Nothing is deleted on this path** — the loose objects stay exactly where they were. case packFailed } /// What one repack actually did, in numbers a test can assert on. public struct GitHousekeepingRepack: Sendable, Equatable { /// How many loose object files the pass found before it started. public let looseBefore: Int /// How many of them libgit2 accepted into the packbuilder. public let inserted: Int /// How many loose files were deleted — which is exactly how many were **proved** to be readable /// out of the newly written pack, one by one, before anything was removed. public let packedAway: Int /// The pack's name (`pack-.pack` / `.idx` under `.git/objects/pack/`). public let packName: String public init(looseBefore: Int, inserted: Int, packedAway: Int, packName: String) { self.looseBefore = looseBefore self.inserted = inserted self.packedAway = packedAway self.packName = packName } } /// How a housekeeping pass ended. public enum GitHousekeepingOutcome: Sendable, Equatable { case repacked(GitHousekeepingRepack) /// The repository has fewer loose objects than the threshold — the ordinary answer, and the one /// almost every board gives almost every time it opens. case belowThreshold(loose: Int) case skipped(GitHousekeepingSkip) } // MARK: - GitHousekeeping /// **Periodic safe housekeeping** (06-history-undo.md ▸ Repository hygiene: "The app may run safe /// libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing"). /// /// ### What it does, and the line it does not cross /// /// libgit2 does no automatic maintenance of its own (14-git-operations.md ▸ A2 → 06), so a board /// that commits every settled change accumulates loose objects forever. This packs them: the same /// objects, byte for byte, moved from one storage form into another. **No commit, no ref, no /// reachable content changes** — the object graph after a pass is the graph before it, and `git log`, /// `git show` and every blob in every tree answer identically. /// /// The whole class of destructive maintenance is **out**, permanently: nothing here prunes, expires a /// reflog, drops an unreachable object, or rewrites a commit. "Deleting never forgets" and "repo /// growth is accepted" are the design's stances (06), and a compaction that made a board smaller by /// forgetting something would contradict both. Unreachable loose objects are packed like any other — /// they stay readable by oid, which is what never-forget means at the object layer. /// /// ### Why deleting a loose file is safe /// /// Every deletion is *provably redundant* before it happens, and the proof is not a chain of /// reasoning about the packbuilder — it is a read: /// /// 1. The loose set is enumerated from the filesystem (`.git/objects//<38 hex>`), so the pass /// knows exactly which files it is considering and never touches anything else under `.git`. /// 2. Each oid is inserted into a `git_packbuilder`, which is then written into /// `.git/objects/pack/`. Writing a pack is purely **additive**: it creates two new files and /// changes nothing that exists. /// 3. The written `.idx` is re-opened as a standalone one-pack object database — no loose backend, /// no repository, nothing that could answer from the very files about to be deleted — and each /// oid is looked up in it. **A loose file is deleted only when that lookup says the object is in /// the new pack.** Anything the lookup does not confirm is left exactly where it is, forever. /// /// A failure at any point returns without deleting anything, so the worst outcome of a broken pass /// is a stray pack file that costs disk and changes no answer. /// /// ### What it deliberately does not do /// /// **It never touches an existing pack** — not to delete one, not to consolidate several into one. /// A repository maintained only by this accumulates roughly one pack per threshold's worth of /// objects, forever, and that is the accepted cost: consolidating means rewriting storage the app did /// not write, on a schedule nobody asked for, with a failure mode (a half-repacked object database) /// far worse than the disk it would save. 06's stance is "repo growth is accepted", and `git gc` in a /// terminal remains exactly as available as it always was for a user who wants more than this. /// /// **It never narrows to reachability.** Every loose object is packed, reachable or not: an object /// no ref can reach is still an object the repository can answer for by oid, and dropping those would /// be the app deciding what history is allowed to remember (06 ▸ Deleting never forgets). /// /// ### Concurrency /// /// The pass is additive-then-provably-redundant, which is what makes a concurrent commit harmless: /// objects a commit writes while this runs are not in the enumerated set, so they are never /// considered, and objects this deletes are readable from the pack the same odb refresh that misses /// the loose file will find. That is the same race `git repack -d` has always had, and the same /// resolution. The scheduler above (`GitHousekeeper`) additionally declines to start while a flush is /// in flight, and the pass itself declines under any pause or held lock — belt and braces over an /// operation that is already safe rather than the thing that makes it safe. /// /// ### Isolation /// /// `GitRepository`'s rule, unchanged: every function is `nonisolated`, opens its own handles, and /// frees them in the same synchronous scope. No handle crosses an `await`, a `Task`, or a stored /// property. enum GitHousekeeping { /// **When a repository has enough loose objects to be worth packing** — git's own `gc.auto` /// default, 6700. /// /// DESIGN names no number ("periodically" is all 06 says), so the number is borrowed from the /// tool whose reason for having one is identical: git picked 6700 as roughly where loose-object /// lookup and directory-scan costs start to matter, and a Lanework board's `.git` is an ordinary /// repository with ordinary objects in it. Borrowing it also means a board the user has been /// running `git gc` on by hand never sees a second opinion about when packing is due. /// /// Injectable at every level above (`GitHousekeeper.threshold`) so a test can spend three objects /// instead of six thousand seven hundred. static let defaultLooseObjectThreshold = 6700 private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") /// libgit2's global state, brought up exactly once per process — `GitCommitOperation.startUp`'s /// rule and its reason (a pass can run when no `Repository` is alive). private static let startUp: Bool = { git_libgit2_init() >= 0 }() // MARK: - The pass /// **Runs one housekeeping pass**, or explains why it didn't. /// /// Synchronous and expected to be called from a detached low-priority task — packing is real CPU /// and real IO, and it is the least urgent work the app does. nonisolated static func run( at boardRoot: URL, threshold: Int = defaultLooseObjectThreshold ) -> GitHousekeepingOutcome { _ = startUp // Two of the three facts every flush checks, asked in the same words // (`GitCommitOperation.reading`) so the engine's vocabulary for "not now" and this one cannot // drift — the third, an unborn HEAD, is nothing to this: a repository with no commits has no // loose objects worth packing and is below any threshold anyway. Housekeeping reads the two it // does take more strictly than the committer does: the committer *holds* and retries, this // simply does not happen this time. let reading = GitCommitOperation.reading(at: boardRoot) if reading.pause != nil { return .skipped(.held) } if reading.isIndexLocked { return .skipped(.indexLocked) } guard BoardGitMode.hasGitEntry(at: boardRoot) else { return .skipped(.noRepository) } var repository: OpaquePointer? guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return .skipped(.noRepository) } defer { git_repository_free(repository) } let objectsDirectory = objectsDirectory(of: repository) let loose = looseObjects(in: objectsDirectory) guard loose.count >= threshold else { return .belowThreshold(loose: loose.count) } return repack(loose, in: repository, objectsDirectory: objectsDirectory) } /// **How many loose objects the repository has right now** — the gate's own reading, exposed /// because it is also the only honest way to assert that a pass reduced the count. /// /// `0` for a board with no repository, which is the same shrug every read in `GitRepository` /// gives one. nonisolated static func looseObjectCount(at boardRoot: URL) -> Int { _ = startUp guard BoardGitMode.hasGitEntry(at: boardRoot) else { return 0 } var repository: OpaquePointer? guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return 0 } defer { git_repository_free(repository) } return looseObjects(in: objectsDirectory(of: repository)).count } // MARK: - Repacking private static func repack( _ loose: [LooseObject], in repository: OpaquePointer, objectsDirectory: URL ) -> GitHousekeepingOutcome { var builder: OpaquePointer? guard git_packbuilder_new(&builder, repository) == 0, let builder else { return .skipped(.packFailed) } defer { git_packbuilder_free(builder) } // Inserted one oid at a time — never `insert_recur`, never `insert_walk`. The set that goes // into the pack is exactly the set enumerated off disk, so "packed" and "considered for // deletion" are the same list by construction, and reachability never enters into it. var inserted: [LooseObject] = [] for object in loose { var oid = git_oid() guard git_oid_fromstr(&oid, object.hex) == 0 else { continue } // A loose object libgit2 cannot read (a truncated write, a corrupt file) is skipped // rather than fatal — and, never having entered the pack, is never a deletion candidate. guard git_packbuilder_insert(builder, &oid, nil) == 0 else { continue } inserted.append(object) } guard !inserted.isEmpty else { return .skipped(.nothingToPack) } // `nil` for the path: libgit2 resolves the repository's own objects/pack directory, which is // one fewer assumption than spelling it here. The name comes back afterwards, and the `.idx` // beside it is what the verification reads. guard git_packbuilder_write(builder, nil, 0, nil, nil) == 0, let namePointer = git_packbuilder_name(builder) else { return .skipped(.packFailed) } let packName = String(cString: namePointer) let indexFile = objectsDirectory .appendingPathComponent("pack", isDirectory: true) .appendingPathComponent("pack-\(packName).idx") guard FileManager.default.fileExists(atPath: indexFile.path) else { // libgit2 said it wrote the pack and the index is not where its own naming says it is. // Nothing is deleted on a fact that surprising. return .skipped(.packFailed) } guard let verifier = OnePackDatabase(indexFile: indexFile) else { return .skipped(.packFailed) } defer { verifier.close() } var packedAway = 0 for object in inserted { // **The proof, read rather than reasoned**: the object is in the pack file just written, // answered by a database that has nothing else in it — no loose backend, no repository, // no alternates. A `false` here (or an oid that will not even parse) leaves the loose // file alone, permanently. guard verifier.contains(object.hex) else { continue } guard (try? FileManager.default.removeItem(at: object.url)) != nil else { continue } packedAway += 1 } logger.debug("housekeeping packed \(packedAway, privacy: .public) of \(loose.count, privacy: .public) loose objects") return .repacked(GitHousekeepingRepack( looseBefore: loose.count, inserted: inserted.count, packedAway: packedAway, packName: packName )) } // MARK: - The loose set /// One loose object: its full hex oid, and the file it lives in. private struct LooseObject { let hex: String let url: URL } /// **Every loose object file under `objects/`**, found by reading the fanout directories. /// /// ### Why the filesystem rather than `git_odb_foreach` /// /// Because `git_odb_foreach` enumerates the *whole* database — packed objects included — and a /// pass that fed already-packed objects back into a new pack would rewrite the entire repository /// into a fresh pack on every run while leaving the old ones in place (nothing here deletes a /// pack, ever). Growth, not hygiene. The loose set is a directory listing by definition, and /// reading it directly is both the exact answer and the cheap one — 256 `readdir`s at background /// priority — and it yields the *file* to delete, which an oid alone does not. /// /// **Strictly shaped, so nothing else can be caught by it**: a two-hex-character directory /// containing thirty-eight-hex-character names. `objects/info`, `objects/pack`, an indexer's /// temp file, an alternates file and anything a user has parked down there all fail the shape and /// are invisible to this. (Thirty-eight is SHA-1's remainder; a SHA-256 repository would simply /// present no loose objects to this pass, which is the safe way for it to be wrong.) private static func looseObjects(in objectsDirectory: URL) -> [LooseObject] { let manager = FileManager.default guard let fanouts = try? manager.contentsOfDirectory(atPath: objectsDirectory.path) else { return [] } var found: [LooseObject] = [] for fanout in fanouts where isHex(fanout, count: 2) { let directory = objectsDirectory.appendingPathComponent(fanout, isDirectory: true) guard let names = try? manager.contentsOfDirectory(atPath: directory.path) else { continue } for name in names where isHex(name, count: 38) { found.append(LooseObject( hex: fanout + name, url: directory.appendingPathComponent(name) )) } } return found } private static func isHex(_ string: String, count: Int) -> Bool { guard string.count == count else { return false } return string.allSatisfy { $0.isHexDigit && !$0.isUppercase } } private static func objectsDirectory(of repository: OpaquePointer) -> URL { let gitDirectory = URL( fileURLWithPath: git_repository_path(repository).map { String(cString: $0) } ?? "", isDirectory: true ) return gitDirectory.appendingPathComponent("objects", isDirectory: true) } // MARK: - The verifier /// **A database containing exactly one pack file and nothing else** — the deletion proof. /// /// It is deliberately not the repository's odb: that one answers from the loose objects too, so /// "the object exists" would be true of every candidate whether or not the pack ever received /// it. With one backend and no alternates, a positive answer can only have come from the pack /// that was just written. private struct OnePackDatabase { private let database: OpaquePointer init?(indexFile: URL) { var database: OpaquePointer? guard git_odb_new(&database) == 0, let database else { return nil } var backend: UnsafeMutablePointer? guard git_odb_backend_one_pack(&backend, indexFile.path) == 0, let backend else { git_odb_free(database) return nil } // The odb takes ownership on success and frees the backend with itself; on failure it // does not, and the backend's own `free` is the only way to give it back. guard git_odb_add_backend(database, backend, 1) == 0 else { backend.pointee.free?(backend) git_odb_free(database) return nil } self.database = database } func contains(_ hex: String) -> Bool { var oid = git_oid() guard git_oid_fromstr(&oid, hex) == 0 else { return false } return git_odb_exists(database, &oid) == 1 } func close() { git_odb_free(database) } } } // MARK: - GitHousekeeper /// **When a housekeeping pass runs** (06-history-undo.md ▸ Repository hygiene) — one per git-mode /// board session, scheduled at board open and never again. /// /// ### Structurally unreachable off Pro /// /// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro /// (`HistoryStore.compose` is the tier gate) — `GitAutoCommitter`'s rule, for its reason. The free /// tier has no housekeeper to disable and no `.git` to pack (12-editions.md ▸ The free tier and /// `.git`, which `InertGitTests` pins against real bytes). /// /// ### Off the open path, on purpose /// /// Board open is where 02-architecture.md's hang-avoidance doctrine is strictest, and packing is the /// single most expensive thing the git layer can do. So the pass is armed with a delay rather than /// run, the delay outlasts the committer's launch catch-up (`GitAutoCommitter.debounceInterval`), the /// work itself runs `Task.detached(priority: .background)`, and the main actor only ever holds the /// verdict. /// /// ### Once, and never retried /// /// A pass that declines — a commit in flight, a paused repository, a held lock — is simply not run; /// nothing re-arms and nothing is surfaced. Loose objects only accumulate, so the next board open /// finds a threshold that is still crossed and tries again then. That is the whole retry policy, and /// it is the right one for work whose failure costs the user nothing. @MainActor @Observable public final class GitHousekeeper { /// The board whose repository this maintains. public let boardRoot: URL /// How many loose objects it takes to be worth a pass. See /// `GitHousekeeping.defaultLooseObjectThreshold`; settable so a test need not make 6700 objects. @ObservationIgnored public var threshold = GitHousekeeping.defaultLooseObjectThreshold /// How long after board open the pass is attempted. /// /// Comfortably past `GitAutoCommitter.debounceInterval` (two seconds), so the launch catch-up /// commit has come and gone before maintenance considers starting — the cheapest possible way to /// keep the two out of each other's way, and settable for the reason every other interval in this /// layer is: a test must not have to spend it. @ObservationIgnored public var delay: Duration = .seconds(8) /// **Whether a commit is in flight right now** — asked on the main actor at the moment of /// dispatch, and answered by the board's own committer (`HistoryStore.activateAutoCommit` wires /// it). /// /// `nil` where no committer exists, which reads as "no", and is the honest answer for a /// housekeeper with no engine beside it. @ObservationIgnored public var isCommitInFlight: (@MainActor () -> Bool)? /// How the last pass ended, or `nil` if none has run. Observable state for tests and for nothing /// else — housekeeping has no surface, by design. public private(set) var lastOutcome: GitHousekeepingOutcome? @ObservationIgnored private var pending: Task? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") init(boardRoot: URL) { self.boardRoot = boardRoot } /// **Arms the one pass this session gets.** Idempotent: a second call while one is armed does /// nothing, so a board opened twice into the same store does not queue two. public func schedule() { guard pending == nil else { return } let delay = delay pending = Task { [weak self] in try? await Task.sleep(for: delay) guard !Task.isCancelled, let self else { return } self.pending = nil await self.runNow() } } /// Cancels an armed pass — the session's teardown, so a closed board's maintenance cannot fire /// against a store that has gone. public func cancel() { pending?.cancel() pending = nil } /// Runs a pass immediately, off the main actor. The scheduled body, and a test's way in. public func runNow() async { // **Never beside a commit.** The pass is safe next to one — it only ever adds a pack and // deletes files it has proved redundant — but "safe" is not "worth it", and optional work // that waits for the next board open costs nothing. if isCommitInFlight?() == true { Self.logger.debug("housekeeping skipped: a commit is in flight") return } let root = boardRoot let threshold = threshold lastOutcome = await Task.detached(priority: .background) { GitHousekeeping.run(at: root, threshold: threshold) }.value } }