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:
2026-07-31 17:06:57 -04:00
parent 1f7d84bf64
commit a7f35a0e5a
7 changed files with 1180 additions and 12 deletions
+58 -2
View File
@@ -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