Build the auto-commit engine

Every settled change on a git-mode board commits, debounced 2s past
drag/typing churn, staged whole-root with .gitignore respected.
GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2
pin SwiftGitX resolves — importable, not duplicated) for
signature-capable commits; add-git's config materialization is gone,
identity resolves at commit time (repo-local config, else derived
default) per the 2026-07-31 ruling in 06. CommitAttribution
classifies per file off EchoLedger receipts: user identity on
app-mediated windows, Lanework External <[email protected]>
on foreign, the modified-by refinement (<slug>@agents.lanework
.invalid) when every foreign file agrees, heal-marked receipts split
into their own commit — window split foreign → heal → user.
Edit-session granularity: ~700ms saves stay uncommitted, staging
excludes open session folders (closure-resolved so mid-session moves
stage around the new location), session end nudges the debounce so
each session lands exactly one body commit. Flush-before-overwrite
gates on known-foreign windows and commits synchronously ahead of
the write; close/quit flush the pipeline via CloseFlushCoordinator's
committerFlush. index.lock backs off briefly then re-debounces
silently; clean tree no-ops; genuine failures ride the standing
history-suspension banner and retry next debounce. Abnormal repo
states (detached HEAD, merge/rebase/cherry-pick in progress) hold
the engine with a 15s re-check; unborn HEAD commits "Initial board
state" whole-tree; dirty tree at open catches up through the same
engine. Message seam (CommitMessageComposing) ships interim — the
semantic composer is the next card.

Discovery diffs HEAD against an in-memory index with rename
detection (git status alone never pairs a bare mv), and a failed
survey reads as "could not look", never "nothing changed".

46 new tests / 8 suites, all real repositories via bundled libgit2.
2240 tests / 383 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 14:10:55 -04:00
parent 189af238a1
commit 3c07c26fda
17 changed files with 3009 additions and 60 deletions
+50
View File
@@ -67,6 +67,21 @@ public final class CardBodyEditSession {
/// Whether the buffer holds keystrokes the file does not.
public var isDirty: Bool { text != disk }
/// **Whether an Edit session is open right now** the body column is showing the editor.
///
/// The fact pro-m1's committer stages around: "a board change committing mid-session excludes
/// the session card's folder from staging, so a lane move never sweeps half-typed body text into
/// its commit" (06-history-undo.md Rules Auto-commit).
///
/// **Open, not dirty**, deliberately: an *open* session is what 06 names, and the exclusion has
/// to cover the moment between a landed ~700 ms save and the next keystroke precisely when the
/// buffer is clean and the file holds a half-typed paragraph no commit should carry yet. A
/// session opened and never typed in costs one card folder its commits until the flip back, and
/// nothing else. (Recorded as a judgment call: 06 says "open Edit sessions", the branch-switch
/// rule qualifies its own gate with "unsaved keystrokes, or on-disk saves the session hasn't
/// committed", and the two readings differ only for the untouched session.)
public private(set) var isEditing = false
// MARK: Seams
/// The debounce interval **~700 ms** (05 Edit), and settable so a test does not have to
@@ -95,6 +110,22 @@ public final class CardBodyEditSession {
@ObservationIgnored
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
/// **The session boundary, announced** called with `true` when an Edit session opens and
/// `false` when it ends, and with nothing in between.
///
/// `CardWindowHost` points it at the board's auto-committer, which registers the card's folder to
/// stage around while the session stands and **nudges** when it ends (06-history-undo.md Rules
/// Auto-commit: the EditPreview flip is "the effective Save button", and raw-source entry and
/// window close end the session too). That nudge is what turns a session's several debounced
/// saves into exactly one commit: they commit nothing while the folder is excluded, and the
/// whole diff becomes committable at once when it is not.
///
/// A closure for `save`'s reason exactly this type is a buffer and a clock, and it stays
/// testable by having no idea what a repository is. `nil` (the free tier, a storeless test) means
/// nothing is listening, which is the same shape every other seam here takes.
@ObservationIgnored
public var editSessionDidChange: ((_ isEditing: Bool) -> Void)?
/// What disk said before this session's **first** landed save the step's before-value, held
/// from the first write until the session ends.
///
@@ -158,6 +189,17 @@ public final class CardBodyEditSession {
return saveNow()
}
/// The start of one Edit session the flip into Edit, or a window that opened straight into it
/// because its card's body was empty (`CardBodyMode.opening(body:)`).
///
/// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published
/// focus value, and a second announcement would register a session that is already registered.
public func beginEditSession() {
guard !isEditing else { return }
isEditing = true
editSessionDidChange?(true)
}
/// The end of one Edit session the flip back to Preview, raw-source entry, or the window
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
/// doc comment).
@@ -173,6 +215,14 @@ public final class CardBodyEditSession {
registerUndo?(origin, disk)
}
sessionOriginBody = nil
// **Last**, after the flush and after the undo step: the committer's nudge must find the
// session's final bytes already on disk, or the commit it arms would carry the file as it
// stood one keystroke ago. Guarded on `isEditing` so a window closing from Preview which
// calls this too, and should announces nothing.
if isEditing {
isEditing = false
editSessionDidChange?(false)
}
return outcome
}