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