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
+37 -4
View File
@@ -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)")