diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 52fadc1..bffc1fd 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -184,6 +184,16 @@ public final class GitAutoCommitter { public private(set) var commitCount = 0 public private(set) var lastCommitOIDs: [String] = [] + /// **Whether a flush is running right now** — the housekeeper's gate (`GitHousekeeper`, + /// 06 ▸ Repository hygiene). + /// + /// A read of the same flag the engine already uses to keep two flushes off each other, published + /// rather than duplicated: the alternative — a second mutual-exclusion mechanism between the + /// committer and optional maintenance — would put a new way to *not* commit into the one path + /// that must always commit. The repack is safe beside a commit either way (`GitHousekeeping` ▸ + /// Concurrency); this is what lets it be polite as well. + public var isCommitInFlight: Bool { isFlushing } + // MARK: - Private state /// Receipts copied out of the ledger at bracket close, keyed by absolute path. Cleared when a diff --git a/Kanban/Git/GitHousekeeping.swift b/Kanban/Git/GitHousekeeping.swift new file mode 100644 index 0000000..dacfebf --- /dev/null +++ b/Kanban/Git/GitHousekeeping.swift @@ -0,0 +1,479 @@ +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 + } +} diff --git a/Kanban/Git/GitRepository.swift b/Kanban/Git/GitRepository.swift index 9ae3414..0288b42 100644 --- a/Kanban/Git/GitRepository.swift +++ b/Kanban/Git/GitRepository.swift @@ -49,8 +49,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve /// main actor never blocks on libgit2 and libgit2 never sees two threads at once. /// /// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's -/// *policy* deliberately left behind: nothing here auto-initializes anything, nothing seeds a -/// `.gitignore` (06 ▸ Repository hygiene, a later card), and nothing commits on its own schedule. +/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on +/// its own schedule. The one seed it does write — a `.gitignore`, at init and never again +/// (06 ▸ Repository hygiene) — is the app's last word on that file rather than the start of a +/// relationship with it. enum GitRepository { /// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states, @@ -73,6 +75,17 @@ enum GitRepository { /// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice. static let initialBranchName = "main" + /// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene: "Adding git + /// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists"). + /// + /// One line, because one line is what the rule says and because every additional entry would be + /// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is + /// the entry that earns its place: the Finder writes one into every folder a user looks at, and + /// on a board that means one per lane and one per card, each churning as icons and window + /// positions move — noise that would otherwise be committed by the whole-tree stage, forever, + /// under the user's own name. + static let seededGitignore = ".DS_Store\n" + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") // MARK: Opt-in init @@ -84,6 +97,9 @@ enum GitRepository { /// is protected from the moment git exists" — so the two halves are one operation and a failure /// in either is one failure. /// + /// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has + /// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`). + /// /// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't /// create" (06), and `git_repository_init` over an existing repository is a re-initialization — /// harmless in the common case and precisely the kind of thing that rule exists to forbid. The @@ -115,6 +131,14 @@ enum GitRepository { return .failure(GitOperationFailure(operation: operation, message: reason(error))) } + // **Before the stage below, so the seed is *in* the initial commit** (06 ▸ Repository + // hygiene). Ordering is the whole of it: written first, `.gitignore` is one of the paths + // `git status` reports and rides into "Initial board state" like any other file — and any + // `.DS_Store` the Finder already left under the board is ignored from the repository's very + // first commit rather than entering history and needing to be forgotten later, which nothing + // in this app will ever do (06 ▸ Deleting never forgets). + seedGitignoreIfAbsent(at: boardRoot) + // **The root commit goes through the same signature-capable path every later commit does** // (`GitCommitOperation`), which is what retired this method's config materialization. // @@ -165,6 +189,38 @@ enum GitRepository { } } + /// **The `.gitignore` seed, written at init and never again** (06-history-undo.md ▸ Repository + /// hygiene: "the app never edits an existing one and never manages the file afterward — it's the + /// user's from then on"). + /// + /// Three properties, and they are the feature: + /// + /// - **Only when absent.** A board that already carries a `.gitignore` — from a template, from a + /// clone, from the user — is left byte for byte alone. `fileExists` rather than a read, so a + /// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks + /// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely + /// because nothing in the app reads this file). + /// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing + /// re-checks it, no heal restores it, no later version of the app appends to it: a user who + /// deletes the seeded line has deleted it. + /// - **Only on the app's own init.** Adoption seeds nothing — an adopted repository is somebody + /// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested + /// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode + /// but `none`, so this function is unreachable from there. + /// + /// A write that fails is not a failure of add-git. The repository exists, the commit that follows + /// simply will not carry a `.gitignore`, and a board with none is an ordinary board — surfacing a + /// banner about a courtesy file would be louder than the thing it reports. + private static func seedGitignoreIfAbsent(at boardRoot: URL) { + let url = boardRoot.appendingPathComponent(".gitignore") + guard !FileManager.default.fileExists(atPath: url.path) else { return } + do { + try Data(seededGitignore.utf8).write(to: url, options: .atomic) + } catch { + logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)") + } + } + // MARK: Reads /// The current branch's short name, or `nil` when there is no repository at `boardRoot` or diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 141ecf1..2bfeab4 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -22,8 +22,9 @@ import os /// ranker. **The provider binding is not part of it** — both tiers still bind /// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the /// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over -/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, remotes -/// and `.gitignore` seeding are each their own card and deliberately absent here. +/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, the +/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed +/// here now; remotes are pro-m2's and deliberately still absent. @MainActor @Observable public final class HistoryStore { @@ -87,6 +88,17 @@ public final class HistoryStore { /// `HistoryStore` built without one has a switcher that can list branches and nothing else. public private(set) var switcher: GitBranchSwitcher? + /// **Periodic safe housekeeping** (06-history-undo.md ▸ Repository hygiene), or `nil` on a board + /// there is no repository to maintain. + /// + /// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason, and + /// it is composed inert for a sharper version of the committer's: packing loose objects is the + /// most expensive thing this layer can do, and board open is where 02-architecture.md's + /// hang-avoidance doctrine is strictest. `activateAutoCommit(_:)` is what arms it — beside the + /// committer, so the two are one decision — and a `HistoryStore` built without a session has a + /// housekeeper that never runs. + public private(set) var housekeeper: GitHousekeeper? + // MARK: - Commit identity /// **What repo-local `.git/config` says right now** — the popover's two fields, as values rather @@ -146,6 +158,7 @@ public final class HistoryStore { if mode == .git { committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger) switcher = GitBranchSwitcher(boardRoot: boardRoot) + housekeeper = GitHousekeeper(boardRoot: boardRoot) } } @@ -161,12 +174,27 @@ public final class HistoryStore { guard let committer else { return } wire(committer) committer.start() + armHousekeeping(beside: committer) } - /// Stops the committer — the session's teardown, so a closed board's debounce cannot fire against - /// a store that has gone. + /// Stops the committer, and the maintenance beside it — the session's teardown, so neither a + /// closed board's debounce nor its housekeeping can fire against a store that has gone. public func stopAutoCommit() { committer?.stop() + housekeeper?.cancel() + } + + /// **Arms the board-open housekeeping pass** (06 ▸ Repository hygiene) — one call site's worth of + /// wiring, shared by the session's activation and by a mid-session add-git, so a board that + /// flipped into git mode maintains itself exactly like one that opened in it. + /// + /// The gate it hands over is the committer's own in-flight flag, read at the moment of dispatch: + /// the simplest honest way to keep optional work from starting beside the one operation that must + /// never be disturbed, and deliberately not a lock — see `GitHousekeeper.runNow()`. + private func armHousekeeping(beside committer: GitAutoCommitter) { + guard let housekeeper else { return } + housekeeper.isCommitInFlight = { [weak committer] in committer?.isCommitInFlight ?? false } + housekeeper.schedule() } /// **The tier gate and the open-time detection, in one line** (12-editions.md ▸ The provider @@ -242,6 +270,11 @@ public final class HistoryStore { // The branch controls appear with the repository they switch branches in — and before // `didAddGit`, which is what wires their seams (`AppModel.wireGitUndo`). switcher = GitBranchSwitcher(boardRoot: root) + // Housekeeping too, for the committer's reason: a board that flipped mid-session behaves + // like one that opened in git mode. A repository seconds old has a handful of loose + // objects and will read below threshold — which is the pass doing its job, not skipping. + housekeeper = GitHousekeeper(boardRoot: root) + armHousekeeping(beside: committer) // Last, after the mode and the committer: the undo binding reads both. didAddGit?() Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)") diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 5058e32..04508c9 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -187,16 +187,20 @@ struct HistoryStoreAddGitTests { #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 { + @Test("Add-git seeds a `.gitignore` into the initial commit") + func addGitSeedsTheIgnoreFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) - #expect(!fixture.exists(".gitignore")) - #expect(!GitRepository.trackedPaths(at: fixture.root).contains(".gitignore")) + // The one file the app ever writes into a board because of git, and the one moment it writes + // it (06 ▸ Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule — the + // untouched existing file, the `.DS_Store` that never enters history, adoption seeding + // nothing; here it is only the fact that add-git's tree includes it. + #expect(fixture.exists(".gitignore")) + #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore")) } @Test("The root commit is authored by the derived default when the repo names nobody") diff --git a/KanbanTests/RepositoryHygieneTests.swift b/KanbanTests/RepositoryHygieneTests.swift new file mode 100644 index 0000000..ecf784d --- /dev/null +++ b/KanbanTests/RepositoryHygieneTests.swift @@ -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 = [] +) 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") + } +} diff --git a/README.md b/README.md index a2c3279..d52d89c 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ Lanework is in early development. This list tracks what has actually shipped and - **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **Most of that is built** — see "Git integration", "Auto-commit", "Undo as forward commits" and "Branches and commit identity" below — and remotes and push/pull are pro-m2's remaining work; the free tier keeps the native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else. -- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. +- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. Add Git also writes a minimal `.gitignore` — one line, `.DS_Store` — if the board hasn't got one, and it lands *inside* that first commit, so the Finder's droppings never enter history in the first place. That is the app's only word on the file: a board that already has one is left byte for byte alone, an adopted repository is seeded nothing at all, and nothing ever edits, appends to or restores it afterwards — it's yours from then on. **Repo growth is accepted, and never repaired by forgetting**: deleting a card removes it from the board but never from history, every version of every attachment stays reachable in any git client, and there is no compaction anywhere in the app because compaction would mean rewriting history. What the app does do is **safe housekeeping**: a board whose repository has accumulated enough loose objects gets them packed once, in the background, a while after opening — the same objects in a more compact form, with not one commit, ref, or byte of content altered, and every loose file proved to be readable out of the new pack before it is removed. It never runs beside a commit, never runs while another program holds the repository, and if it doesn't run it simply doesn't: there is no retry and nothing to see. -- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: `.gitignore` seeding and remotes. +- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m2: remotes and push/pull. - **Undo as forward commits (Lanework Pro)** — on a git board ⌘Z and ⇧⌘Z stop being an in-memory stack and become the commit trail itself. There is no stored stack anywhere: **the stack *is* HEAD's first-parent ancestry**, re-read from the repository, so it survives relaunch for free and nothing beside the repo can ever drift from it. **A restore is a new commit, never a rewind** — no reset, no force, no rewritten history: ⌘Z materializes the earlier state and commits it as "Undo: Move card 'Fix login' to Doing", ⇧⌘Z as "Redo: …", and every commit you have ever made stays exactly where it was, inspectable in any git client. Only the *difference* is written, so a card you happen to be editing that the change never touched is simply left alone. Because the app is not the only writer, the stack re-reads HEAD before every keystroke: an agent that committed its own work in the last twenty minutes becomes the top of the stack, so ⌘Z steps back exactly one commit and can never silently swallow somebody else's session — and any commit arriving from anywhere clears redo, the classic rule. The app's own repairs are **transparent**: a heal commit is never a step and is never reverted by one, so a ⌘Z run walks past it instead of fighting the healer. Anything still pending commits *before* the restore does, so both versions of what you undid exist in the trail. When the change would land on a card you have open in Edit or Raw Source, the restore stops and asks — **Save All**, **Discard**, or **Cancel** — rather than silently committing text you hadn't saved or quietly writing it back a moment later; a raw buffer that won't validate cancels the whole thing and puts you in front of the window that refused. Undo is board-local, disabled while an outside-the-app merge or rebase has the repository paused, and absent altogether on boards the app manages no git for — where the Edit menu's rows and the toolbar's twins simply dim. Adding git to an open board turns it on there and then. - **Branches and commit identity (Lanework Pro)** — the board popover's git section is where a git board's branches live: the current branch is itself the picker, and beside the local branches it offers create-and-switch, which starts the new branch at the commit you are on. **Switching is never silent.** If any card window is holding unsaved keystrokes, an open Edit session whose crash-safe saves no commit has yet, or an open raw-source buffer, the switch stops and asks — **Save All**, **Discard**, or **Cancel** — for *every* open window, not just the ones the checkout would touch, because an unapplied raw buffer would otherwise write the whole pre-switch file onto the new branch's card later on. Save All ends each session with its normal commit and applies each raw buffer; a buffer that won't validate cancels the whole switch and puts you in front of the window that refused, nothing half-switched. Discard puts both the buffers and their uncommitted on-disk saves back to the last commit. With the sessions settled the pending auto-commit flushes onto the branch you are **leaving**, so the checkout runs on a genuinely settled tree and cannot fail dirty — and the checkout itself is git's *safe* one, never a force: work the app somehow didn't settle refuses the switch rather than being overwritten. The switch is bracketed like every wholesale operation — the watcher suspended, one full reload at the end, an in-progress row saying "Switching to 'main'…" — and if that final reload fails, the board locks read-only until a reload succeeds rather than letting you edit a snapshot of the branch you just left. Undo and redo do not survive the switch: the stack is discarded and reseeded from the new branch's own history, with redo empty, because replaying a restore from the old branch onto the new one would be wrong. When another program holds the repository's index, the switch waits and retries quietly, the row changing to say it is waiting for another writer's git lock and eventually naming the lock file — never a dialog, and the lock is never removed, because it isn't the app's. **An interrupted switch cleans up after itself**: before touching the repository the app writes down what it is about to do — in its own state, never in your board and never inside `.git` — so a crash or an unplugged volume mid-switch is recognized at the next open, rolled back to the state you were in, and announced ("A branch switch was interrupted — the previous state is restored"); an unfinished merge or rebase that *isn't* the app's is still left strictly alone. Beneath the branches sit the **commit-identity** fields: what you type there is written to the repository's own `.git/config`, so the setting is the file — portable to any git client, per board, and what the next commit is signed with. Leave a field empty and it shows the derived default (your account name, and `you@yourmac`) as a placeholder rather than filling it in, because a value the app wrote there would quietly outrank your own global git config for that board. A config edited from outside while the popover is open refreshes the fields you aren't typing in and leaves the one you are alone. - **A card's History (Lanework Pro)** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git and throughout the free tier — no placeholder, no greyed-out promise.