Files
lanework/Kanban/Git/GitAutoCommitter.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

736 lines
37 KiB
Swift

import Foundation
import os
// MARK: - GitLandedWindow
/// **One debounce window's commits, as the undo stack needs to hear about them** — see
/// `GitAutoCommitter.reportLanded`.
///
/// The heal halves are separated because the two rules they serve are separate: `healOIDs` is what
/// makes the *pointer* pass over a heal commit, and `healPaths` is what makes a restore's
/// materialized diff **exclude** the paths whose divergence is heal work (06-history-undo.md ▸ Rules
/// ▸ Heal commits are transparent to undo, in-session — both halves, stated in one sentence).
public struct GitLandedWindow: Sendable, Equatable {
/// Every commit the window landed, oldest first.
public let commits: [GitLandedCommit]
/// Board-root-relative paths this window committed as heal work.
public let healPaths: Set<String>
public init(commits: [GitLandedCommit], healPaths: Set<String>) {
self.commits = commits
self.healPaths = healPaths
}
/// The oids of the heal-class commits — the ones the undo pointer passes over.
public var healOIDs: Set<String> {
Set(commits.filter { $0.kind == .heal }.map(\.oid))
}
/// Whether *everything* this window landed was heal work.
///
/// The distinction the stack acts on: a window of nothing but heal commits must leave the undo
/// pointer and the redo stack exactly where they were — "the fresh heal commit is in-session,
/// transparent, and the undo run continues past it" (06). A window carrying anything else is an
/// ordinary arrival, and arrivals clear redo.
public var isEntirelyHeal: Bool {
!commits.isEmpty && commits.allSatisfy { $0.kind == .heal }
}
}
// MARK: - GitAutoCommitter
/// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced
/// past drag and typing churn, on git-mode boards and nowhere else.
///
/// ### 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). The free tier therefore has no committer to disable,
/// no debounce to cancel and no `.git` to touch — 12-editions.md's inert posture as a shape rather
/// than as a flag, which `InertGitTests` pins against real bytes.
///
/// ### What arms it
///
/// Two signals, both from `BoardStore` through `HistoryCommitSeam`, and both meaning "the tree may
/// have moved":
///
/// - **A write bracket closed.** The app just wrote. This is also where receipts are *harvested* —
/// see `HarvestedReceipt` for why the committer cannot simply read the ledger two seconds later.
/// - **A reload landed.** Which covers foreign changes on the same debounce as app-mediated ones:
/// "Agent and hand edits arrive through the watcher like any change and get auto-committed on the
/// same debounce" (06 ▸ Interaction with external writers). It covers strays too — the reload
/// lands whether or not the snapshot changed, and "its commit condition is the *tree*, not the
/// snapshot diff, so a stray-only window commits".
///
/// The committer's own commits do not re-arm it: `FolderWatcher` filters `.git`'s internals, so
/// writing an index, an object and a ref produces no event at all. The pathfinder relied on the
/// clean-tree no-op to break that echo; here there is no echo to break.
///
/// ### Isolation
///
/// `@MainActor` for the state — the debounce task, the harvest, the session registry — and every
/// piece of libgit2 work runs in a `Task.detached` over `Sendable` values (`FlushInput`), which is
/// `GitRepository`'s rule restated: the main actor never blocks on libgit2, and libgit2 never sees
/// two threads on one handle. The **one** deliberate exception is `noteWillWrite()`; see its note.
@MainActor
@Observable
public final class GitAutoCommitter {
// MARK: - Identity
/// The board this commits, which in git mode is also the repository's working-tree root.
public let boardRoot: URL
/// **This board's write-provenance ledger** (`BoardStore.echoes`), read — never written — at the
/// close of every write bracket.
@ObservationIgnored
private let ledger: EchoLedger
// MARK: - Seams
/// **The debounce** — how long the tree must be quiet before a commit.
///
/// Two seconds is the pathfinder's interval, kept because the cadence constraint (06 ▸ Rules)
/// asks the same thing of it as the pathfinder did: long enough that a drag, a multi-select
/// delete and a burst of typing each land as one commit, short enough that a board's history is
/// never far behind its files. Settable for `CardBodyEditSession.debounceInterval`'s reason
/// exactly — a test must not have to spend it.
@ObservationIgnored
public var debounceInterval: Duration = .seconds(2)
/// How long to wait between attempts when `index.lock` is held, and how many times.
///
/// "If the auto-committer finds the index locked (an agent's commit in flight), it backs off
/// briefly and retries; if the lock persists, it simply re-debounces" (06 ▸ Interaction with
/// external writers). *Briefly* is the operative word: a held lock is another writer doing its
/// job, and the pending changes lose nothing by waiting for the next quiet moment.
@ObservationIgnored
public var lockRetryDelay: Duration = .milliseconds(120)
@ObservationIgnored
public var lockRetryAttempts = 3
/// How long a held repository waits before re-checking its own state.
///
/// **Not a retry** — nothing is attempted — but the pause has to end somehow: "edits keep landing
/// on disk and commit as one settled batch when the state clears", and a rebase finished in a
/// terminal that moves only refs produces no watcher event at all (`.git` is filtered), so
/// nothing else would ever nudge this board again. A `git_repository_state` read is a handful of
/// `stat`s; at this cadence, only while a pause stands, it is the cheapest thing that keeps the
/// promise. 07's "never hammer" is about not retrying the *operation*, which this never does.
@ObservationIgnored
public var holdRecheckInterval: Duration = .seconds(15)
/// **What a commit says** — the seam, holding the semantic composer by default
/// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was
/// asked for without asserting what it said.
@ObservationIgnored
public var composer: any CommitMessageComposing = SemanticCommitMessage()
/// The board as the app last read it, for the composer's "current" half. `nil` where no store is
/// attached, which is every storeless test.
@ObservationIgnored
public var currentSnapshot: (@MainActor () -> BoardModel?)?
/// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but
/// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
///
/// Deliberately **not** called for lock contention, which "is never an error", nor for a held
/// repository, whose surface is the popover's badge (the branch-switching card's).
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// History is advancing again — the standing suspension's clearing rule
/// (`BannerCenter.clearHistorySuspension`), which is "the ordinary shape of commit succeeded".
@ObservationIgnored
public var reportRecovery: (@MainActor () -> Void)?
/// **What a flush landed, and which of it was heal work** — the undo stack's in-session ear
/// (06-history-undo.md ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live; ▸ Heal commits
/// are transparent to undo, in-session).
///
/// Two facts travel here and nowhere else can carry them. **Liveness**: "foreign commits …
/// push onto the in-session undo stack as ordinary steps as they land", and a commit this engine
/// made is the one kind of arrival the stack could otherwise only discover by polling HEAD.
/// **Heal transparency**: the heal class is known from the Writer's heal-marked receipts, which
/// this engine clears the instant a window commits — so the moment of landing is the only moment
/// at which "that commit was the heal" is knowable at all.
///
/// `nil` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer
/// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none and
/// repo-nested boards alike have a native stack and no committer.
@ObservationIgnored
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
// MARK: - Observable state
/// **The repository state the engine is holding for**, or `nil` when it is free to commit
/// (06 ▸ Rules ▸ Abnormal repo states).
///
/// Queryable rather than merely internal because the *UI* surface of the pause — the popover's
/// badge and its plain-language explanation, and disabling Undo/Redo, the branch controls, Pull
/// and Push with it — is the branch-switching card's, and it needs exactly this fact. What this
/// card owns is the hold itself.
public private(set) var pause: GitRepositoryPause?
/// The last genuine failure, or `nil` if history is advancing. Beside `pause` for the popover's
/// sake, and because `HistoryStore.lastFailure` is add-git's, not this.
public private(set) var lastFailure: GitOperationFailure?
/// Commits this committer has landed, and the newest OID — the debounce's own testimony, which a
/// test would otherwise have to infer from a commit walk.
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
/// flush commits them — the window is over, and a stale receipt would vouch for the next window's
/// changes.
@ObservationIgnored
private var harvested: [String: HarvestedReceipt] = [:]
/// **Whether this window holds a change nobody vouched for** — the flush-before-overwrite gate.
@ObservationIgnored
private var holdsForeignChanges = false
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
///
/// A closure per session rather than a stored URL, because a card can move lane, or into the
/// trash, in the middle of a session — its folder is a fact about the current snapshot, not
/// about when the window opened.
@ObservationIgnored
private var cardSessions: [UUID: @MainActor () -> URL?] = [:]
@ObservationIgnored
private var pending: Task<Void, Never>?
@ObservationIgnored
private var isFlushing = false
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, ledger: EchoLedger) {
self.boardRoot = boardRoot
self.ledger = ledger
}
// MARK: - Lifecycle
/// **Launch catch-up** (06 ▸ Commit messages: "changes found pending at board open … through the
/// same composer, instead of committing blind").
///
/// One armed debounce and nothing else: a board that opens clean spends one `git status` and
/// commits nothing, and a board that opens dirty commits through the ordinary engine — same
/// split, same authorship, same message seam. Everything found pending classifies **foreign**,
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
/// and "the app never vouches for changes it didn't witness".
public func start() {
arm()
}
/// Stops the engine and forgets the window. Called at teardown so a closed board's debounce
/// cannot fire against a store that has gone.
public func stop() {
pending?.cancel()
pending = nil
}
// MARK: - Inbound signals
/// **A write bracket closed** — harvest, then arm.
///
/// The harvest is the whole reason this signal exists separately from the reload: receipts
/// describe a completed write and are consumed by the landing reload that classifies them, so
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
public func noteWriteBracketClosed() {
harvest()
arm()
}
/// **A reload landed** — the tree settled, and here is whether any of it was somebody else's.
///
/// - Parameter sawForeignChange: what the landing reload's own `EchoLedger.verdicts` concluded.
/// It arms flush-before-overwrite and nothing else; the commit split re-derives provenance per
/// *file* at flush time, because this is one bit about a whole reload.
public func noteReloadLanded(sawForeignChange: Bool) {
if sawForeignChange { holdsForeignChanges = true }
arm()
}
/// **Flush-before-overwrite** (06 ▸ Rules): "before an app write overwrites on-disk state that
/// differs from the last-loaded snapshot … the pending auto-commit is flushed so the external
/// version enters history first. *Both versions exist as commits* is thereby a guarantee, not a
/// likelihood."
///
/// ### The gate
///
/// It fires **only when the window holds a change the app does not vouch for**. That is exactly
/// the condition under which overwriting can bury someone else's uncommitted version; a window
/// of nothing but the app's own writes has nothing to protect, and flushing there would commit
/// once per gesture and make the cadence constraint's "unbearable shared log" come true.
///
/// ### The two costs, recorded
///
/// **It runs on the main actor, synchronously.** `performWrite` is synchronous — it is a
/// gesture's write path — so an ordering guarantee *before* it can only be kept by a synchronous
/// commit. 02's hang-avoidance doctrine and 06's ordering guarantee genuinely conflict here, and
/// the guarantee wins for an operation that is rare (foreign change pending), bounded (one
/// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a
/// version of somebody's file with no commit to recover it from).
///
/// **The semantic composer widened that bound**, and it is recorded rather than discovered: this
/// flush now also materializes HEAD's tree and reads it back through `BoardLoader`
/// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than
/// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the
/// commit that preserves somebody else's version, would be the worst message in the trail.
///
/// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns
/// about foreign changes from landed reloads, so a write that lands inside the watcher's own
/// debounce is not yet known to be pending. Bounded by that debounce, and the same window
/// 05-card-window.md's dirty-buffer rule already calls last-writer-wins — but it is a real gap in
/// "guarantee", and it is recorded here rather than discovered later.
public func noteWillWrite() {
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
isFlushing = true
defer { isFlushing = false }
pending?.cancel()
pending = nil
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
// the foreign version commits on the next quiet debounce instead — which is the same
// "re-debounce" answer contention gets everywhere else.
let result = Self.execute(input)
apply(result.outcome, healPaths: result.healPaths)
}
// MARK: - Card-window sessions
/// **Registers an open card window's folder** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31 —
/// "Board history sees **card-window sessions, not gestures**"):
///
/// > while a card's window is open, everything happening inside it — the body editor's ~700 ms
/// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes —
/// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the
/// > former Edit-session stage-around, widened; comments included).
///
/// So the unit is the **window**, not the body's Edit session: the token is minted when the
/// window joins its board and released when its session ends, and everything the window writes in
/// between — body saves, comment posts and deletes, inline comment edits, the composer's draft,
/// the `comments/.trash/` purge — is inside one folder that no interim flush can see.
///
/// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never
/// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray
/// dropped inside the session card's folder therefore waits for the session to end, along with
/// everything else under it — a foreign write to the same card included, which is what makes the
/// close flush's two-commit split the *first* moment that change can land (06 ▸ Rules ▸
/// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window
/// mixes foreign changes to that card with the app's own").
///
/// - Parameters:
/// - token: the window's identity, so ending twice is idempotent.
/// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged
/// around at wherever it now is.
public func beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
cardSessions[token] = cardFolder
}
/// Ends one, and **nudges** — which is what makes "window close flushes the session as one
/// commit" true: the session's writes committed nothing while the window stood, and this is the
/// moment its whole diff becomes committable (06 ▸ Rules ▸ Auto-commit).
///
/// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs
/// to completion first — `AppModel.unregisterCardWindow`), so the diff this arms over is the
/// session's *final* state rather than its second-to-last.
public func endCardSession(_ token: UUID) {
guard cardSessions.removeValue(forKey: token) != nil else { return }
arm()
}
// MARK: - The pause, asked for
/// **Re-reads the repository's state without attempting anything** — what the popover's git
/// section calls when it appears (06 ▸ Rules ▸ Abnormal repo states: "the popover's git section
/// names the state plainly").
///
/// The engine learns about a pause by *trying to commit* and being held, which is the right
/// cadence for committing and the wrong one for a surface: a board opened into a detached HEAD
/// would show live branch controls for as long as the debounce takes to fire. This is the same
/// read the flush takes (`GitCommitOperation.reading`), asked by a surface instead of by a write,
/// and it changes nothing else — no arming, no retry, no commit.
///
/// A flush landing while this is in flight wins, which is correct: it read the repository later
/// and it read it in order to write.
public func refreshPause() async {
let root = boardRoot
pause = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
}
/// Whether a card window's folder is currently staged around — the stage-around rule, made
/// assertable without reaching into private state.
public var stagedAroundFolders: [URL] {
cardSessions.values.compactMap { $0() }
}
// MARK: - Flushing
/// **Commits now**, cancelling the debounce — the close/quit path, and File ▸ Duplicate's
/// pending-work step.
///
/// 02-architecture.md § Windows fixes where it sits: "closing a board window (and app quit) first
/// closes the board's card windows — each open Edit session ends with its normal session commit —
/// then flushes pending debounced work, editor saves before the pending auto-commit, before the
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
/// sessions have ended, so nothing is staged around any more.
public func flushNow() async {
await flush()
}
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
/// typing churn" is one timer rather than a rule each call site remembers.
private func arm(after interval: Duration? = nil) {
pending?.cancel()
let delay = interval ?? debounceInterval
pending = Task { [weak self] in
try? await Task.sleep(for: delay)
guard !Task.isCancelled, let self else { return }
self.pending = nil
await self.flush()
}
}
private func flush() async {
guard !isFlushing else { return }
isFlushing = true
defer { isFlushing = false }
pending?.cancel()
pending = nil
guard let input = makeInput() else { return }
// The brief backoff. Off the main actor for the git work, on it for the sleep, so a held
// lock costs a couple of suspended turns rather than a blocked UI.
for attempt in 0...max(0, lockRetryAttempts) {
let result = await Task.detached(priority: .utility) { Self.execute(input) }.value
if case .locked = result.outcome, attempt < max(0, lockRetryAttempts) {
try? await Task.sleep(for: lockRetryDelay)
continue
}
apply(result.outcome, healPaths: result.healPaths)
return
}
}
// MARK: - The plan
/// Everything one flush needs, as values — so the whole of it can cross to a detached task.
private struct FlushInput: Sendable {
let boardRoot: URL
let excludedFolders: [String]
let receipts: [String: HarvestedReceipt]
let composer: any CommitMessageComposing
let snapshot: BoardModel?
}
private func makeInput() -> FlushInput? {
FlushInput(
boardRoot: boardRoot,
excludedFolders: stagedAroundKeys,
receipts: harvested,
composer: composer,
snapshot: currentSnapshot?()
)
}
/// What one flush concluded — the outcome, plus the paths it committed as heal work.
///
/// The second half exists for `reportLanded`: heal paths are known only inside the split, which
/// runs here, and the stack that needs them lives on the main actor.
private struct FlushOutput: Sendable {
let outcome: GitCommitOutcome
var healPaths: Set<String> = []
}
/// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around
/// the open sessions, split by provenance, compose, commit.
private nonisolated static func execute(_ input: FlushInput) -> FlushOutput {
let reading = GitCommitOperation.reading(at: input.boardRoot)
if let pause = reading.pause { return FlushOutput(outcome: .held(pause)) }
if reading.isIndexLocked { return FlushOutput(outcome: .locked) }
// `nil` is "the survey could not be taken" — an unwritable object store, a corrupt index —
// and it must not read as a clean tree: that would no-op silently and let history stop
// advancing with nothing on the banner strip (06 ▸ Interaction with external writers, the
// genuine-failure clause).
guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else {
return FlushOutput(outcome: .failed(GitOperationFailure(
operation: "Recording this board's history",
message: "this board's repository could not be read"
)))
}
let changed = surveyed
.filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) }
guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) }
let commits = plan(
changed,
reading: reading,
input: input,
composition: composition(for: changed, input: input)
)
return FlushOutput(
outcome: GitCommitOperation.perform(at: input.boardRoot, commits: commits),
healPaths: Set(commits.filter { $0.kind == .heal }.flatMap(\.paths))
)
}
// MARK: - What the composer is handed
/// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural
/// diff of two board snapshots — last-committed vs. current").
///
/// Once per *flush*, not once per planned commit: a window that splits three ways
/// (foreign → heal → user) composes all three messages against the same HEAD, so materializing
/// HEAD's tree three times would be three answers to one question. Each message is then narrowed
/// to its own commit by `CommitMessageRequest.changedPaths`, which the split already narrows.
private struct Composition: Sendable {
var previous: BoardModel?
var current: BoardModel?
var agentGuideText: String?
}
/// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept
/// here so `CommitMessageEngine` can be a pure function of values.
///
/// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary
/// stray-only and guide-only window: those compose path-shaped events, and materializing a board
/// twice to describe a changed `.gitignore` would be work with no reader.
private nonisolated static func composition(
for changed: [GitChangedPath],
input: FlushInput
) -> Composition {
var composition = Composition()
if changed.contains(where: { $0.path == AgentGuide.filename }) {
composition.agentGuideText = try? String(
contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename),
encoding: .utf8
)
}
// **The comment family needs a board but not a diff.** Comments are outside the snapshot
// entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about
// them — but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a
// card's title. So a comment-only window loads the current board and skips the materialization.
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
guard touchesModel || namesACard else { return composition }
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
// a real configuration (`HistoryStore.compose` without a session, every engine-level test), and
// a composer handed no current board could only ever shrug. Loading here rather than in
// `makeInput` keeps the read off the main actor, where every other read in this flush already
// is.
composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model)
guard touchesModel else { return composition }
composition.previous = GitHeadSnapshot.load(at: input.boardRoot)
return composition
}
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
private nonisolated static func plan(
_ changed: [GitChangedPath],
reading: GitRepositoryReading,
input: FlushInput,
composition: Composition
) -> [PlannedCommit] {
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
func request(
_ paths: [GitChangedPath],
_ authorship: CommitAuthorship,
isRootCommit: Bool = false
) -> CommitMessageRequest {
CommitMessageRequest(
boardRoot: input.boardRoot,
changedPaths: paths,
authorship: authorship,
isRootCommit: isRootCommit,
snapshot: composition.current,
previousSnapshot: composition.previous,
agentGuideText: composition.agentGuideText
)
}
// **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole
// tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed
// snapshot to diff against". Splitting a repository's first commit three ways by the
// provenance of files that mostly predate the app knowing about them would be a fiction; the
// whole tree arriving at once is the event, and it is the user's own opt-in that caused it,
// so it is authored as the user. (Recorded as a judgment call: DESIGN fixes the subject and
// the shape, not the author.)
guard !reading.isUnborn else {
return [PlannedCommit(
paths: changed.map(\.path),
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
author: user,
committer: user,
kind: .root
)]
}
let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts)
return split.ordered.map { group in
let authorship: CommitAuthorship
switch group.kind {
case .foreign:
authorship = .foreign(
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
)
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
// heal's paths commit *separately* and says nothing about who they are by. The healer is
// the app acting on the user's behalf — its writes are app-mediated, receipt and all — so
// authoring them as the user is the honest reading, and authoring them as `Lanework
// External` would blame the outside world for the app's own repair.
case .heal: authorship = .heal
case .user: authorship = .user
}
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign
case .heal: kind = .heal
case .user: kind = .user
}
return PlannedCommit(
paths: group.paths.map(\.path),
message: input.composer.message(for: request(group.paths, authorship)),
author: author,
committer: user,
kind: kind
)
}
}
/// Whether a changed path lives inside a folder staged around.
private nonisolated static func isExcluded(
_ relativePath: String,
under boardRoot: URL,
by folders: [String]
) -> Bool {
guard !folders.isEmpty else { return false }
let absolute = EchoLedger.key(boardRoot.appendingPathComponent(relativePath))
return folders.contains { absolute == $0 || absolute.hasPrefix($0 + "/") }
}
// MARK: - Outcomes
private func apply(_ outcome: GitCommitOutcome, healPaths: Set<String> = []) {
switch outcome {
case let .committed(landed):
let oids = landed.map(\.oid)
pause = nil
lastFailure = nil
commitCount += oids.count
lastCommitOIDs = oids
// **The stack hears about every commit this engine lands** (06 ▸ Rules ▸ The stack is
// HEAD's first-parent ancestry, live), *before* the receipts that describe them are
// cleared below — the heal class exists only for as long as they do.
reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths))
// The window is over: its receipts have said everything they can say, and keeping them
// would let them vouch for the *next* window's changes to the same paths.
dropHarvestOutsideOpenSessions()
holdsForeignChanges = false
reportRecovery?()
Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)")
case .nothingToCommit:
// **The happy path, not a malfunction** (06): an agent committed its own work, or the
// whole window was staged around. Silent, and the window closes either way.
pause = nil
lastFailure = nil
dropHarvestOutsideOpenSessions()
holdsForeignChanges = false
reportRecovery?()
case .locked:
// "No banner, no log-worthy failure: a held lock is another writer doing its job." The
// changes are still pending and the harvest is still held, so the next quiet moment
// commits them with their provenance intact.
Self.logger.debug("index.lock held — re-debouncing")
arm()
case let .held(reason):
pause = reason
Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)")
arm(after: holdRecheckInterval)
case let .failed(failure):
pause = nil
lastFailure = failure
Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)")
reportFailure?(failure)
arm()
}
}
// MARK: - Harvest
/// Copies the ledger's current receipts into this window's own record.
///
/// Whole-ledger rather than bracket-scoped, deliberately: the Writer's primitives drop receipts
/// without telling anyone which paths they were, and a diff of key sets would miss a
/// *supersession* (the same key, newer bytes) — which is exactly the case that must not be
/// missed, since the newest write is the one disk will be compared against. Copying is cheap:
/// the ledger holds tens of entries, and the harvest happens once per gesture.
private func harvest() {
for (path, entry) in ledger.outstandingEntries() {
harvested[path] = entry
}
}
/// **Forgets the receipts a flush has spent — and keeps the ones it could not** (06 ▸ Interaction
/// with external writers: attribution "per file", off the ledger).
///
/// A receipt is cleared because the commit it described has landed. Under the widened
/// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its
/// receipts have not been spent at all: they describe writes still sitting uncommitted on disk,
/// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at
/// close wrong in exactly the case it exists for — the app's own body save and comment posts would
/// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world
/// for the user's own session.
///
/// So the drop is scoped to what the flush could see: everything outside every open session's
/// folder goes, everything inside one stays until that session's own commit spends it.
private func dropHarvestOutsideOpenSessions() {
let open = stagedAroundKeys
guard !open.isEmpty else {
harvested.removeAll()
return
}
harvested = harvested.filter { key, _ in
open.contains { key == $0 || key.hasPrefix($0 + "/") }
}
}
/// The open sessions' folders as `EchoLedger` keys — what both the staging exclusion and the
/// harvest's scoped drop compare against, resolved in one place so they cannot disagree.
private var stagedAroundKeys: [String] {
cardSessions.values.compactMap { $0() }.map(EchoLedger.key)
}
}