Files
lanework/Kanban/Git/HistoryStore.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 21:19:42 -04:00

359 lines
20 KiB
Swift

import Foundation
import os
// MARK: - HistoryStore
/// **A board's git state** (02-architecture.md ▸ Components ▸ HistoryStore): which mode the board
/// opened in, the repository behind it when there is one, and the two operations that can change
/// either — the app's own add-git, and nothing else.
///
/// ### One per board session, composed under the tier
///
/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so
/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a
/// `.git` — "any `.git` is inert … the app never reads history, never commits, never touches `.git`
/// in any way" (12-editions.md ▸ The free tier and `.git`), which `InertGitTests` pins against real
/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type
/// exists.
///
/// ### What it does not do yet
///
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
/// ranker. **The provider binding reads `mode` and nothing else about a tier** — the composition
/// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested`
/// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and
/// what a repo-nested board denies is app-managed history, never ⌘Z). 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 {
/// The board this is the git state of. The board root *is* the repository's working-tree root
/// in git mode — that is what mode `git` means.
public let boardRoot: URL
/// **Detected once, at composition, and changed by exactly one thing afterwards.**
///
/// "Detection is nearest-`.git`-wins, checked at every board open … never mid-session"
/// (06-history-undo.md ▸ Rules). A `git init` run in a terminal under an open board therefore
/// takes effect at its *next* open — the watcher does not scan for `.git` appearing, and nothing
/// re-runs `BoardGitMode.detect` for the life of this object.
///
/// The one deliberate mid-session transition is `addGit()` below: "the rule forbids *discovered*
/// flips, never commanded ones."
public private(set) var mode: BoardGitMode
/// The current branch's short name in git mode, `nil` until it has been read (or when there is
/// nothing to read).
///
/// Filled by `refreshBranch()` rather than at composition, deliberately: composition happens on
/// the board-open path, where 02-architecture.md's hang-avoidance doctrine says nothing may
/// block, and opening a repository is libgit2 work — small, but work. Detection is a `stat`;
/// this is a read, and it waits until the popover actually asks.
public private(set) var branch: String?
/// Whether add-git is in flight — the button's disabled state, and the guard that keeps a double
/// click from running `git_repository_init` twice.
public private(set) var isAddingGit = false
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
///
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
/// add-git's failure surface either way.
public private(set) var lastFailure: GitOperationFailure?
/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board
/// there is no repository to commit into.
///
/// Its existence is exactly `mode == .git`, and that invariant is the tier gate one level down:
/// no `HistoryStore` off Pro means no committer anywhere off Pro, with nothing to disable and no
/// flag to forget.
///
/// **Composed inert and started separately.** Composition happens on the board-open path, where
/// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what
/// `AppModel.beginSession` calls once the store, the banner strip and the card windows are
/// reachable, and it is what arms the launch catch-up. A `HistoryStore` built without a session —
/// a test, a storeless consumer — therefore has a committer that never runs.
public private(set) var committer: GitAutoCommitter?
/// **The branch controls** (06-history-undo.md ▸ Branch switching), or `nil` on a board there is
/// no repository to switch branches in.
///
/// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason — and
/// like the committer it is composed inert: the seams that make it a *sequence* (the settle step,
/// the store's bracket, the undo reseed, the banner strip) arrive from the session, and a
/// `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
/// than as a resolved identity (06 ▸ Interaction with external writers: "The board popover's git
/// section exposes name/email fields that write that repo-local config — the setting *is* the
/// file").
///
/// Empty means the file names no such key, which is what an empty field means: the derived default
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
/// be the app writing its own guess into the user's repository the first time they edited anything
/// else in the popover — the exact thing 06 rules out.
public private(set) var identityName = ""
public private(set) var identityEmail = ""
/// **The derived default**, for the placeholders — `nil` until `refreshIdentity()` has run.
///
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
/// off the main actor with the config, when the popover asks.
public private(set) var derivedIdentity: GitIdentity?
/// The last identity-write failure, surfaced as an inline caption in the popover beside the fields
/// — 06's popover-anchored posture ("the user asked from a form still under their eye"), which is
/// exactly where `lastFailure` above already puts add-git's.
public private(set) var identityFailure: GitOperationFailure?
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
/// same one the session's store owns.
@ObservationIgnored
private let ledger: EchoLedger
/// How the session wires a committer up, remembered so the one built by a mid-session add-git
/// gets the same treatment as the one composed at open.
@ObservationIgnored
private var autoCommitWiring: ((GitAutoCommitter) -> Void)?
/// **The mid-session mode flip, announced** — called once, after a successful `addGit()`, and
/// never on any other path.
///
/// It exists because the flip has a second consumer beyond the committer: the board's **undo
/// substrate**. A session that composed on a mode-none board bound the native stack
/// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip
/// means the board now has a trail to be an undo stack over instead — "add-git swaps the
/// substrate mid-session … discards the in-session native stack and seeds the git trail from the
/// root commit" (13-native-undo.md). What that swap means is
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
/// keep that decision out of a git state that has no business knowing what a provider is.
@ObservationIgnored
public var didAddGit: (@MainActor () -> Void)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
self.boardRoot = boardRoot
self.mode = mode
self.ledger = ledger
if mode == .git {
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
switcher = GitBranchSwitcher(boardRoot: boardRoot)
housekeeper = GitHousekeeper(boardRoot: boardRoot)
}
}
/// **Wires the committer into its session and starts it** — `AppModel.beginSession`'s call.
///
/// Separate from composition for two reasons that point the same way: the seams a committer needs
/// (the banner strip, the board's snapshot, the card windows' Edit sessions) belong to a session
/// that does not exist when `compose` runs, and arming a debounce is a side effect no *detection*
/// should have. The wiring is remembered because add-git can produce a committer later, and a
/// board that flipped into git mode mid-session must commit exactly like one that opened in it.
public func activateAutoCommit(_ wire: @escaping (GitAutoCommitter) -> Void) {
autoCommitWiring = wire
guard let committer else { return }
wire(committer)
committer.start()
armHousekeeping(beside: committer)
}
/// 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
/// seam; 06-history-undo.md ▸ Rules ▸ Detection) — called by `AppModel.beginSession` beside the
/// entitlement read that supplies `tier`.
///
/// `nil` under `.free` means exactly what it says: no git state exists for that session, so no
/// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says
/// right now, and a board that has changed mode since its last open simply opens in the new one
/// — "the app just reflects what it finds".
///
/// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in
/// `.git` here, silently, with no dialog and nothing to confirm — "the repo's presence *is* the
/// opt-in" (06 ▸ Rules ▸ Adoption).
///
/// - Parameter ledger: the board's write-provenance ledger (`BoardStore.echoes`) — what the
/// auto-committer classifies each changed file against. Defaulted to a fresh one so a
/// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the
/// honest answer for a git state with no session behind it (everything reads foreign, the
/// launch-catch-up doctrine).
public static func compose(boardRoot: URL, tier: Tier, ledger: EchoLedger = EchoLedger()) -> HistoryStore? {
guard tier == .pro else { return nil }
let mode = BoardGitMode.detect(boardRoot: boardRoot)
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger)
}
// MARK: - Add git
/// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and
/// immediately commits the whole tree as "Initial board state".
///
/// Reachable from one place — the board popover's git section under Pro — and from nowhere else:
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
/// under every board it opened.
///
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
/// mid-session transition: "clicking it flips the open board into git mode immediately — the
/// popover flows straight into the git controls". The flip is commanded, not discovered, which
/// is what distinguishes it from the `git init` a user runs in a terminal under an open board.
///
/// Only mode `none` can be added to. Mode `git` has nothing to add, and a repo-nested board is
/// one the app "leaves strictly alone" — no nested repo, ever.
@discardableResult
public func addGit() async -> Bool {
guard mode == .none, !isAddingGit else { return false }
isAddingGit = true
lastFailure = nil
defer { isAddingGit = false }
let root = boardRoot
// Off the main actor: `git_repository_init` plus a whole-tree stage and commit is real
// filesystem work, and the popover it was clicked in stays live while it runs.
let outcome = await Task.detached(priority: .userInitiated) {
GitRepository.create(at: root)
}.value
switch outcome {
case .success(let branchName):
mode = .git
branch = branchName
// **The commanded mid-session flip, carried through to the engine** (06 ▸ Rules ▸
// Detection: "clicking it flips the open board into git mode immediately — the popover
// flows straight into the git controls, the first auto-commit follows"). The root commit
// has already landed inside `create`, so what `start()` arms here finds a clean tree and
// no-ops; what it buys is that the *next* settled change commits, exactly as on a board
// that opened in git mode.
let committer = GitAutoCommitter(boardRoot: root, ledger: ledger)
self.committer = committer
autoCommitWiring?(committer)
committer.start()
// 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)")
return true
case .failure(let failure):
lastFailure = failure
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
return false
}
}
/// Reads the current branch name into `branch` — the popover's read-only display line, refreshed
/// when the popover opens. A no-op outside git mode.
public func refreshBranch() async {
guard mode == .git else { return }
let root = boardRoot
branch = await Task.detached(priority: .userInitiated) {
GitRepository.branchName(at: root)
}.value
}
// MARK: - Commit identity
/// Reads repo-local config and the derived default into the popover's fields. A no-op outside git
/// mode, `refreshBranch()`'s rule.
///
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
/// account and host names.
public func refreshIdentity() async {
guard mode == .git else { return }
let root = boardRoot
let read = await Task.detached(priority: .userInitiated) {
(
repoLocal: GitCommitOperation.repoLocalIdentity(at: root),
derived: GitIdentity.derivedDefault()
)
}.value
identityName = read.repoLocal.name ?? ""
identityEmail = read.repoLocal.email ?? ""
derivedIdentity = read.derived
}
/// **Writes the fields into repo-local `.git/config`** — "the setting *is* the file".
///
/// An empty value clears its key rather than writing an empty string, which is what the
/// placeholder promises: an empty field means the derived default applies. The read afterwards is
/// not ceremony — it is how the fields end up showing what the file says rather than what was
/// typed at it, which is the only version that survives a foreign edit landing in between.
public func writeIdentity(name: String, email: String) async {
guard mode == .git else { return }
let root = boardRoot
identityFailure = nil
let outcome = await Task.detached(priority: .userInitiated) {
GitCommitOperation.writeRepoLocalIdentity(name: name, email: email, at: root)
}.value
if case let .failure(failure) = outcome {
identityFailure = failure
Self.logger.error("identity write failed: \(failure.description, privacy: .public)")
}
await refreshIdentity()
}
// MARK: - The loader's history seam
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for — the
/// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's
/// remaining rungs (birth date, then traversal order).
///
/// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and
/// only if something actually asks — which is only when a duplicate identity was found, since
/// that is the only thing `BoardLoader.dedupeIdentities` consults it for. A ranker cached across
/// loads would answer from a history that has since moved; one built per load never can.
public var identityHistoryRanker: BoardLoader.IdentityHistoryRanker? {
guard mode == .git else { return nil }
return GitPathHistory(boardRoot: boardRoot).ranker
}
}