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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user