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
+72 -1
View File
@@ -745,6 +745,25 @@ public final class AppModel {
// at the moment of each load rather than at composition.
if let git {
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
// Rules Auto-commit). Called on every Pro session and not only on git-mode ones,
// because add-git can flip a board mid-session and the committer it builds then must land
// in exactly this shape `activateAutoCommit` remembers the wiring for that.
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
// contention and a held repository never reach here neither is a failure.
committer.reportFailure = { [weak store] failure in
store?.banners.suspendHistory(reason: failure.message)
}
committer.reportRecovery = { [weak store] in
store?.banners.clearHistorySuspension()
}
store.commitSeam = .binding(to: committer)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
@@ -791,6 +810,43 @@ public final class AppModel {
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
// A window that left without its session ending a crash-shaped teardown, or a dismissal
// that raced the flush must not leave its card folder excluded from staging forever.
setEditSession(false, for: ref)
}
/// Tokens the committer knows each card window's Edit session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var editSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's Edit session opened or closed** (06-history-undo.md Rules Auto-commit:
/// the committer "stages around open Edit sessions").
///
/// This is the honest seam between the two halves of the rule: `CardBodyEditSession` knows a
/// session is open, the committer knows what staging is, and only the app model knows which board
/// a card window belongs to and how to reach its committer. A board with no committer the free
/// tier, a Pro board with no repository records nothing, which is the same `nil` every other
/// git seam takes.
///
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
/// both containers, which is exactly why the body save uses it too.
func setEditSession(_ isOpen: Bool, for ref: CardWindowRef) {
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
if isOpen {
let token = editSessionTokens[ref] ?? UUID()
editSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginEditSession(token) { [weak store = session.store] in
guard let store,
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
return path.folder(under: store.rootURL)
}
} else if let token = editSessionTokens.removeValue(forKey: ref) {
committer.endEditSession(token)
}
}
// MARK: - Launch failures
@@ -1017,7 +1073,17 @@ public final class AppModel {
// 02-architecture.md puts them ("each open Edit session ends with its normal session
// commit", then pending work). The slot stays for a board-level editor with no card
// window of its own the raw-source buffer is the candidate so that the order
// relative to `committerFlush` (m7) is already decided when one arrives.
// relative to `committerFlush` is already decided when one arrives.
//
// **`committerFlush` is filled now** (06-history-undo.md Rules Auto-commit: "Board
// window close and app quit flush the pipeline any pending editor save, then the
// pending auto-commit before teardown; nothing settled is ever left unsaved or
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
// Edit session, so nothing is staged around and each session's body lands in exactly one
// commit. `nil` on every board with no committer, which is the whole free tier.
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
@@ -1036,6 +1102,11 @@ public final class AppModel {
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
// The committer dies with the session it commits for, `history.clear()`'s reason
// exactly: its debounce holds a closure over the store this line is about to release,
// and a timer that outlived its board would fire against a repository nobody is
// looking at. Its pending work has already been flushed by `committerFlush` above.
session.git?.stopAutoCommit()
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
// Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
// Cleared rather than merely dropped because the steps hold closures over the store
+11
View File
@@ -523,6 +523,17 @@ struct CardWindowHost: View {
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
}
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
}
// **The stage-around registry's one wire** (06-history-undo.md Rules Auto-commit). The
// buffer announces its session boundary, the app model knows which board this card belongs
// to, and the committer knows what staging is; this line is the join, and it is the only
// place all three are in scope. A free-tier board or any board with no repository has no
// committer, so `setEditSession` records nothing and the buffer never learns the difference.
session.body.editSessionDidChange = { [appModel, ref] isEditing in
appModel.setEditSession(isEditing, for: ref)
}
Self.configureRawSource(
rawSource,
body: session.body,
+261
View File
@@ -0,0 +1,261 @@
import Foundation
// MARK: - A harvested receipt
/// **One EchoLedger receipt, copied out for the committer** (02-architecture.md Components
/// EchoLedger; 06-history-undo.md Interaction with external writers).
///
/// ### Why a copy and not a read
///
/// The ledger's receipts are **consumed** by the landing reload that classifies them "one write,
/// one echo", which is what buys the announcer its silence. The committer asks its question two
/// seconds later, by which time several reloads have landed and every receipt for the user's own
/// card edit is gone. Reading the live ledger at flush time would therefore attribute the user's own
/// work to `Lanework External`, which is the one misattribution this whole mechanism exists to
/// prevent.
///
/// So the committer harvests at the **close of each write bracket** the moment a receipt describes
/// a completed write and nothing has had a chance to consume it and keeps its own copy for the
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
///
/// The satisfaction check stays the ledger's rule, re-applied against disk at commit time, so the
/// two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app write
/// classify app-mediated, and a foreign edit that misses the hash classifies foreign.
public struct HarvestedReceipt: Sendable, Equatable {
public let receipt: EchoLedger.Receipt
/// **Whether the write that dropped it was a heal** the flag 06 (ruled 2026-07-29) keys the
/// third commit class on: "a debounce window holding a scheduled heal's changes alongside anyone
/// else's splits the heal's paths into their own commit".
public let isHeal: Bool
public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
self.receipt = receipt
self.isHeal = isHeal
}
}
// MARK: - The split
/// One debounce window's changed paths, divided into the commits they will become.
///
/// **Three classes, committed in this order** foreign, then heal, then the user's:
///
/// - *Foreign first* is 06's own ordering, stated as a consequence of flush-before-overwrite:
/// "flush-before-overwrite already orders them: foreign first, then the user's overwrite". The log
/// then reads causally what arrived, then what the user did about it.
/// - *Heal in the middle* is a judgment call, recorded: DESIGN fixes the heal's **separation** and
/// not its position. A scheduled heal repairs what a load found, so it follows the foreign change
/// that usually caused it and precedes the user's gesture, which is the order the three actually
/// happened in.
public struct CommitSplit: Sendable, Equatable {
/// Changes nobody vouched for an agent, a text editor, a terminal, or a blind window at launch.
public var foreign: [GitChangedPath] = []
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
/// them (`EchoLedger.markHeal`).
public var heal: [GitChangedPath] = []
/// The user acting through the app.
public var user: [GitChangedPath] = []
public init() {}
/// One class of one window's changes, ready to become a commit.
public struct Group: Sendable, Equatable {
public let paths: [GitChangedPath]
/// Which class it is carried rather than re-derived, so the planner never has to ask a
/// list whether it contains its own members.
public let kind: Kind
public enum Kind: Sendable, Equatable { case foreign, heal, user }
}
/// The classes in commit order, empty ones dropped what the planner turns into `PlannedCommit`s.
public var ordered: [Group] {
[
Group(paths: foreign, kind: .foreign),
Group(paths: heal, kind: .heal),
Group(paths: user, kind: .user)
].filter { !$0.paths.isEmpty }
}
public var isEmpty: Bool { foreign.isEmpty && heal.isEmpty && user.isEmpty }
}
// MARK: - CommitAttribution
/// **Who a commit is by** (06-history-undo.md Interaction with external writers: "Commit
/// attribution is structural, not just a message convention").
///
/// A pure enum of statics over values: the changed paths, the harvested receipts, and the bytes on
/// disk. Nothing here opens a repository, so every rule below is provable from a fixture rather than
/// from a commit graph.
public enum CommitAttribution {
// MARK: The pinned identities
/// **API, not decoration** (06): "The strings are API (users script against them; the `.invalid`
/// TLD honestly marks a non-routable synthetic identity) they change with the deliberateness
/// of a schema change."
public static let externalAuthorName = "Lanework External"
public static let externalAuthorEmail = "[email protected]"
/// The domain a self-reported `modified-by` stamp authors under "distinct from both the user
/// and the generic external author".
public static let agentEmailDomain = "agents.lanework.invalid"
/// The frontmatter key a foreign writer refines its own attribution with
/// (01-storage-format.md; 08-agent-integration.md teaches it).
static let modifiedByKey = "modified-by"
public static var externalIdentity: GitIdentity {
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
}
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
/// slugified)".
///
/// The local part is lowercased on top of the slug a judgment call, recorded: DESIGN says
/// "slugified" without fixing case, addresses are conventionally lower, and the guide's own
/// example stamp is `modified-by: claude`. The display name is untouched, so `Claude Code` still
/// renders as `Claude Code <claude-code@agents.lanework.invalid>`.
public static func agentIdentity(named displayName: String) -> GitIdentity {
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let local = GitIdentity.addressComponent(name, fallback: "agent").lowercased()
return GitIdentity(name: name.isEmpty ? externalAuthorName : name, email: "\(local)@\(agentEmailDomain)")
}
// MARK: - Classification
/// **Every changed file, sorted into its commit** the per-file rule 06 states, applied to the
/// paths `git status` reported.
///
/// A path is the app's when the ledger holds a receipt for it (or for a folder above it) that
/// **disk still satisfies**; it is a heal when that receipt is heal-marked; it is foreign
/// otherwise. "No receipt anywhere foreign" is the launch-catch-up doctrine and the whole of
/// *the app never vouches for changes it didn't witness*.
///
/// ### Why the walk goes up the folders
///
/// Because the ledger keys some facts at *folders* while git only ever reports *files*. A card
/// the app moved between lanes has one `.move` receipt on its folder and no receipt at all on
/// the `index.md` that travelled inside it; a card the app deleted has one `.absence` receipt on
/// its folder and git reports every file underneath as gone. Asking only the file's own key
/// would classify both as foreign the user's own delete, attributed to an agent.
///
/// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with
/// its own content receipt rather than with the move above it.
public static func split(
_ paths: [GitChangedPath],
under boardRoot: URL,
receipts: [String: HarvestedReceipt]
) -> CommitSplit {
var split = CommitSplit()
for path in paths {
let absolute = EchoLedger.key(boardRoot.appendingPathComponent(path.path))
switch vouched(forAbsolutePath: absolute, boardRoot: boardRoot, receipts: receipts) {
case .none: split.foreign.append(path)
case .some(true): split.heal.append(path)
case .some(false): split.user.append(path)
}
}
return split
}
/// `nil` when nothing vouches for this path; otherwise whether the vouching receipt was a heal.
private static func vouched(
forAbsolutePath absolute: String,
boardRoot: URL,
receipts: [String: HarvestedReceipt]
) -> Bool? {
let root = EchoLedger.key(boardRoot)
var candidate = absolute
while candidate.hasPrefix(root), candidate.count >= root.count {
if let held = receipts[candidate] {
switch held.receipt {
case let .content(hash):
// Content is a claim about *these* bytes, so only the file's own key may answer
// with it. A content receipt sitting on an ancestor would be a claim about a
// folder's bytes, which is not a thing.
if candidate == absolute {
return hash == hashOfFile(atPath: absolute) ? held.isHeal : nil
}
case .absence:
return exists(candidate) ? nil : held.isHeal
case let .move(from, to):
if candidate == to { return exists(candidate) ? held.isHeal : nil }
if candidate == from { return exists(candidate) ? nil : held.isHeal }
}
}
guard candidate != root else { break }
let parent = (candidate as NSString).deletingLastPathComponent
guard parent != candidate else { break }
candidate = parent
}
return nil
}
// MARK: - The foreign author
/// **`modified-by` refines foreign attribution** (06): the whole rule, as one function.
///
/// > when every file changed in a foreign debounce window carries the same `modified-by: X`,
/// > that commit is authored as **X** Any disagreement between stamps, any unstamped changed
/// > file, or any true deletion in the window falls back to `Lanework External`.
///
/// **A folder move is not a deletion.** A rename's departure end is a file that is gone from
/// disk and has no stamp to read, but it is not "a deletion [that] leaves no file to stamp"
/// its arrival end is right there in the same window, carrying whatever the writer stamped on
/// it. So a paired departure is skipped rather than demoting the window. A bare `mv` that
/// re-stamps nothing still demotes, through the unstamped-file clause, exactly as 06 says it
/// does which is why the agent guide teaches re-stamping on move.
///
/// A window of nothing but rename departures leaves no stamp to agree on and falls back too.
public static func foreignIdentity(for paths: [GitChangedPath], under boardRoot: URL) -> GitIdentity {
var stamps: Set<String> = []
for path in paths {
if path.isDeletion {
guard path.isRename else { return externalIdentity }
continue
}
guard let stamp = modifiedBy(atRelativePath: path.path, under: boardRoot) else {
return externalIdentity
}
stamps.insert(stamp)
}
guard stamps.count == 1, let name = stamps.first else { return externalIdentity }
return agentIdentity(named: name)
}
/// The `modified-by` a changed file carries, or `nil` for a file that carries none **which
/// every non-`index.md` path does, by construction**: a stray, an attachment, and `CLAUDE.md`
/// have no frontmatter to stamp, so they are unstamped changed files and demote the window.
static func modifiedBy(atRelativePath relativePath: String, under boardRoot: URL) -> String? {
guard relativePath == BoardLoader.indexFileName
|| relativePath.hasSuffix("/" + BoardLoader.indexFileName) else { return nil }
let url = boardRoot.appendingPathComponent(relativePath)
guard let text = try? String(contentsOf: url, encoding: .utf8),
let document = try? FrontmatterDocument.parse(text),
let raw = document.rawValue(for: modifiedByKey) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
// MARK: - Disk
private static func exists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path)
}
/// The hash of what is at `path` now, or `nil` when nothing is the same digest the ledger's
/// receipts were minted with, so the comparison is the ledger's own.
private static func hashOfFile(atPath path: String) -> String? {
guard let data = FileManager.default.contents(atPath: path) else { return nil }
return EchoLedger.hash(of: data)
}
}
+104
View File
@@ -0,0 +1,104 @@
import Foundation
// MARK: - Authorship
/// Which of the three classes a commit is the axis the message engine is allowed to know about.
///
/// The composer receives it because 06-history-undo.md Commit messages gives one rule that turns
/// on it (the root commit's fixed subject) and one that deliberately does not: "**Foreign commits
/// speak the same vocabulary.** Origin lives in the author field (structural attribution), not in
/// message prose a foreign move reads 'Move card ' exactly like an app-mediated one". The
/// composer card therefore gets the fact and is expected to ignore it for phrasing; having it means
/// it never has to be plumbed later, and having it *named* means the rule about not using it has
/// something to point at.
public enum CommitAuthorship: Sendable, Equatable {
/// The user acting through the app.
case user
/// A scheduled heal's own commit (ruled 2026-07-29).
case heal
/// Everything else, carrying the author it will be committed under.
case foreign(GitIdentity)
}
// MARK: - The request
/// Everything the message engine is handed for one commit.
///
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
/// composer the next card plugs into it without reshaping the engine: it can start reading
/// `snapshot` and HEAD's tree the day it lands, and any input it turns out to need joins this type
/// rather than every call site.
public struct CommitMessageRequest: Sendable {
/// The board this is a commit in and, for the composer card, where HEAD's tree is read from
/// for the last-committed half of its diff.
public let boardRoot: URL
/// **The changed-path list** (06 Commit messages Non-snapshot files commit too: "beside the
/// snapshot diff it receives the changed-path list, and non-snapshot paths compose *path-shaped
/// events*"), narrowed to the paths *this* commit stages.
public let changedPaths: [GitChangedPath]
/// Which class this commit is.
public let authorship: CommitAuthorship
/// Whether this is the repository's first commit the one commit with a subject of its own
/// ("Initial board state", 06 Rules Abnormal repo states).
public let isRootCommit: Bool
/// The board as the app last read it, or `nil` where no store is attached (a storeless
/// committer, a test). The current half of the composer's "last-committed vs. current" diff; the
/// other half is HEAD's tree, which the composer reads for itself.
public let snapshot: BoardModel?
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
authorship: CommitAuthorship,
isRootCommit: Bool,
snapshot: BoardModel?
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
self.authorship = authorship
self.isRootCommit = isRootCommit
self.snapshot = snapshot
}
}
// MARK: - The seam
/// **What a commit says** (06-history-undo.md Commit messages).
///
/// The real implementation is the next card's: "a pure, testable function" composing from a
/// structural diff of two board snapshots, with the whole Add/Delete/Move/Rename/Edit vocabulary,
/// plural folding, path-shaped events for non-snapshot files, and the trash pair. None of that
/// exists yet; what exists is this protocol, so that arriving card is one type conforming here
/// rather than a change to the engine that calls it.
///
/// `Sendable` because composition runs off the main actor, inside the same detached task that stages
/// and commits the message has to be in hand before `git_commit_create` is called, and none of the
/// work is main-actor work.
public protocol CommitMessageComposing: Sendable {
func message(for request: CommitMessageRequest) -> String
}
// MARK: - The interim
/// **The placeholder message**, deliberately the *fallback* 06 already names rather than an
/// invention: "genuinely mixed windows fall back to 'Update board'".
///
/// So the trail an interim build writes is a trail the composer card only ever makes *more*
/// specific no message written today becomes wrong tomorrow, and the root commit's subject is
/// already the settled one.
public struct InterimCommitMessage: CommitMessageComposing {
/// 06's own mixed-window fallback.
public static let fallbackSubject = "Update board"
public init() {}
public func message(for request: CommitMessageRequest) -> String {
request.isRootCommit ? GitRepository.initialCommitSubject : Self.fallbackSubject
}
}
+511
View File
@@ -0,0 +1,511 @@
import Foundation
import os
// 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 the semantic composer plugs into (next card).
@ObservationIgnored
public var composer: any CommitMessageComposing = InterimCommitMessage()
/// 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)?
// 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] = []
// 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 Edit 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 Edit was entered.
@ObservationIgnored
private var editSessions: [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).
///
/// **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.
apply(Self.execute(input))
}
// MARK: - Edit sessions
/// **Registers an open Edit session's card folder** (06 Rules Auto-commit: "The committer
/// stages around open Edit sessions: 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").
///
/// 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
/// the body.
///
/// - 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 beginEditSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
editSessions[token] = cardFolder
}
/// Ends one, and **nudges** which is what makes "exactly one body commit per session" true:
/// the session's debounced saves committed nothing while it was open, and this is the moment its
/// whole diff becomes committable (06 Rules Auto-commit: the EditPreview flip is "the
/// effective Save button"; raw-source entry and window close end the session too).
public func endEditSession(_ token: UUID) {
guard editSessions.removeValue(forKey: token) != nil else { return }
arm()
}
/// 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] {
editSessions.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 outcome = await Task.detached(priority: .utility) { Self.execute(input) }.value
if case .locked = outcome, attempt < max(0, lockRetryAttempts) {
try? await Task.sleep(for: lockRetryDelay)
continue
}
apply(outcome)
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: editSessions.values.compactMap { $0() }.map(EchoLedger.key),
receipts: harvested,
composer: composer,
snapshot: currentSnapshot?()
)
}
/// **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) -> GitCommitOutcome {
let reading = GitCommitOperation.reading(at: input.boardRoot)
if let pause = reading.pause { return .held(pause) }
if reading.isIndexLocked { return .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 .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 .nothingToCommit }
return GitCommitOperation.perform(
at: input.boardRoot,
commits: plan(changed, reading: reading, input: input)
)
}
/// 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
) -> [PlannedCommit] {
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
// **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: CommitMessageRequest(
boardRoot: input.boardRoot,
changedPaths: changed,
authorship: .user,
isRootCommit: true,
snapshot: input.snapshot
)),
author: user,
committer: user
)]
}
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 }
return PlannedCommit(
paths: group.paths.map(\.path),
message: input.composer.message(for: CommitMessageRequest(
boardRoot: input.boardRoot,
changedPaths: group.paths,
authorship: authorship,
isRootCommit: false,
snapshot: input.snapshot
)),
author: author,
committer: user
)
}
}
/// 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) {
switch outcome {
case let .committed(oids):
pause = nil
lastFailure = nil
commitCount += oids.count
lastCommitOIDs = oids
// 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.
harvested.removeAll()
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
harvested.removeAll()
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
}
}
}
+676
View File
@@ -0,0 +1,676 @@
import Foundation
import libgit2
import os
// MARK: - Repository state
/// **A repo state the auto-committer holds for** (06-history-undo.md Rules Abnormal repo
/// states): "a detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app
/// git pauses the git surface honestly auto-commit holds".
///
/// **Unborn HEAD is deliberately absent.** It is *normal* git mode "the first auto-commit creates
/// the root commit on the branch HEAD names, and the undo trail simply starts empty" so it is a
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
/// to stop.
///
/// The cases are libgit2's own `git_repository_state`, which reads exactly the marker files 06
/// names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case detachedHead
case merge
case revert
case cherryPick
case bisect
case rebase
case applyMailbox
/// What the popover will say **the branch-switching card's surface, phrased here** so the
/// engine-side hold and the sentence that explains it cannot drift apart (06 Rules Abnormal
/// repo states: "the popover's git section names the state plainly and says resolving it
/// belongs to the tool that created it").
public var explanation: String {
switch self {
case .detachedHead: "HEAD is detached — commits would belong to no branch"
case .merge: "a merge is in progress"
case .revert: "a revert is in progress"
case .cherryPick: "a cherry-pick is in progress"
case .bisect: "a bisect is in progress"
case .rebase: "a rebase is in progress"
case .applyMailbox: "a patch application is in progress"
}
}
}
/// What one look at the repository found, before any staging is attempted.
public struct GitRepositoryReading: Sendable, Equatable {
/// The pause 06 holds for, or `nil` when the repository is in a state the committer may write in.
public let pause: GitRepositoryPause?
/// Whether HEAD names a branch that has no commits yet normal git mode, and the one thing that
/// makes the next commit a root commit.
public let isUnborn: Bool
/// Whether `index.lock` is held right now. Read as a file rather than inferred from a failure so
/// the committer can back off *before* it has written anything (06 Interaction with external
/// writers: "`index.lock` contention is never an error").
public let isIndexLocked: Bool
public init(pause: GitRepositoryPause?, isUnborn: Bool, isIndexLocked: Bool) {
self.pause = pause
self.isUnborn = isUnborn
self.isIndexLocked = isIndexLocked
}
}
// MARK: - Changed paths
/// One path `git status` reports as differing between HEAD and the working tree.
///
/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or
/// removes exactly these, and attribution asks a question per *file* (06 Interaction with external
/// writers: "classify every observed change, per file").
public struct GitChangedPath: Sendable, Equatable, Hashable {
/// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot).
public let path: String
/// Whether the file is **gone** from the working tree.
///
/// The `modified-by` rule turns on this bit "any true deletion in the window falls back to
/// `Lanework External` a deletion leaves no file to stamp" which is why the rename half
/// below is a separate fact rather than folded in here.
public let isDeletion: Bool
/// Whether this path is one end of a **rename** libgit2 paired up.
///
/// "**A folder move is not a deletion**: items match by id across the whole board so a moved
/// card attributes by its stamp like any changed file" (06). A paired departure is therefore a
/// deletion on disk that the window must not be demoted by.
public let isRename: Bool
public init(path: String, isDeletion: Bool, isRename: Bool) {
self.path = path
self.isDeletion = isDeletion
self.isRename = isRename
}
}
// MARK: - A planned commit
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
///
/// A value rather than a call, because the flush's whole decision the three-way split, the
/// ordering, the authorship is made on the main actor from state the committer holds, and the
/// libgit2 work is then a pure function of these (06 Interaction with external writers: the
/// two-commit split; ruled 2026-07-29: the heal's third class).
public struct PlannedCommit: Sendable, Equatable {
/// Board-root-relative paths, exactly as `GitChangedPath.path` spells them.
public let paths: [String]
public let message: String
/// **Who the change is by** the user, `Lanework External`, or a `modified-by` agent.
public let author: GitIdentity
/// **Who made the commit** always this machine's user identity.
///
/// A judgment call, recorded: 06 pins the *author* ("foreign changes are committed under the
/// pinned synthetic author so any git client can filter, log, and blame by origin" and both
/// `git log --author` and `git blame` read the author field) and says nothing about the
/// committer. Git's own convention for recording somebody else's change `git am`, cherry-pick,
/// every forge's merge button keeps the author as the originator and names the actor who
/// created the commit as committer, which is honestly what happened here: Lanework, running as
/// this user, wrote it. Setting both to the synthetic identity would claim the repository made
/// itself.
public let committer: GitIdentity
public init(paths: [String], message: String, author: GitIdentity, committer: GitIdentity) {
self.paths = paths
self.message = message
self.author = author
self.committer = committer
}
}
/// How a flush ended the four outcomes 06 gives the committer, and no fifth.
public enum GitCommitOutcome: Sendable, Equatable {
/// One commit per planned commit that had anything in it, oldest first.
case committed([String])
/// **The happy path, not a malfunction** (06 Interaction with external writers): the tree had
/// nothing to commit an agent already committed its own work, or the window held only paths
/// staged around.
case nothingToCommit
/// **Never an error** (06): `index.lock` was held and stayed held through the brief retry. The
/// caller re-debounces; nothing is surfaced.
case locked
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Edits keep
/// landing on disk and commit as one settled batch when it clears.
case held(GitRepositoryPause)
/// A genuine failure disk full, corruption. Surfaced per 02-architecture.md Write-failure
/// surfacing and retried on the next debounce.
case failed(GitOperationFailure)
}
// MARK: - GitCommitOperation
/// **The signature-capable commit path** (06-history-undo.md Interaction with external writers:
/// "Commit attribution is structural, not just a message convention"), written against the vendored
/// libgit2 C API directly.
///
/// ### Why it is not SwiftGitX
///
/// SwiftGitX 0.4.0's `Repository.commit(message:)` takes no signature: its `CommitOptions` leaves
/// `author` and `committer` null, so libgit2 falls back to `git_signature_default`, which resolves
/// through the merged config ladder unreadable in the sandbox, and the wrong question anyway
/// (06 rules `~/.gitconfig` out of the identity story entirely). Per-commit authorship is this
/// card's whole point: the user's identity on user-driven commits, `Lanework External` on foreign
/// ones, a `modified-by` agent's on stamped ones. None of that is reachable through the wrapper, and
/// `Repository.pointer` is `internal`, so there is no seam to borrow either.
///
/// The module underneath *is* reachable SwiftGitX vendors `libgit2` as a package product, and
/// `project.yml` names the same pin SwiftGitX pins, so this adds an import rather than a second copy
/// of the library. Everything SwiftGitX does well (`GitRepository`'s reads) still goes through it.
///
/// ### Isolation
///
/// `GitRepository`'s rule, unchanged and for its reason: every function here is `nonisolated`, opens
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
/// `await`, a `Task`, or a stored property, so libgit2 never sees two threads on one handle.
enum GitCommitOperation {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state, brought up exactly once per process.
///
/// SwiftGitX calls `git_libgit2_init` from `Repository.init`/`open` and pairs it with a shutdown
/// in `deinit`, which is a refcount this file must not ride on: a flush can run when no
/// `Repository` is alive. A `static let` is Swift's own run-once, and the matching shutdown is
/// deliberately never called the library stays up for the life of the process, which is what
/// every consumer here wants.
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
// MARK: - Reading
/// The three facts a flush checks **before every attempt** (06 Rules Abnormal repo states:
/// "the check runs at open and again before every flush, so finishing the operation in a
/// terminal resumes the pipeline without ceremony").
///
/// A repository that cannot be opened at all reads as no pause, not unborn, not locked the
/// same shrug every read in `GitRepository` gives an unopenable repo, and the commit attempt
/// that follows will fail honestly with libgit2's own message rather than on a guess made here.
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
_ = startUp
guard let repository = open(boardRoot) else {
return GitRepositoryReading(pause: nil, isUnborn: false, isIndexLocked: false)
}
defer { git_repository_free(repository) }
let locked = isIndexLocked(gitDirectory: gitDirectory(of: repository))
let unborn = git_repository_head_unborn(repository) == 1
// Detached HEAD is asked first because it is the state an unborn repo cannot be in and the
// one `git_repository_state` does not model: libgit2 keeps "what operation is in progress"
// and "where HEAD points" as separate questions.
if !unborn, git_repository_head_detached(repository) == 1 {
return GitRepositoryReading(pause: .detachedHead, isUnborn: false, isIndexLocked: locked)
}
return GitRepositoryReading(pause: pause(of: repository), isUnborn: unborn, isIndexLocked: locked)
}
/// libgit2's `git_repository_state`, mapped to the pauses 06 names.
///
/// It reads the marker files the design lists (`rebase-merge/`, `rebase-apply/`, `MERGE_HEAD`,
/// `REVERT_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) which is why a test can plant one file and
/// get the real answer rather than a mocked one.
private static func pause(of repository: OpaquePointer) -> GitRepositoryPause? {
switch git_repository_state(repository) {
case Int32(GIT_REPOSITORY_STATE_NONE.rawValue): nil
case Int32(GIT_REPOSITORY_STATE_MERGE.rawValue): .merge
case Int32(GIT_REPOSITORY_STATE_REVERT.rawValue),
Int32(GIT_REPOSITORY_STATE_REVERT_SEQUENCE.rawValue): .revert
case Int32(GIT_REPOSITORY_STATE_CHERRYPICK.rawValue),
Int32(GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE.rawValue): .cherryPick
case Int32(GIT_REPOSITORY_STATE_BISECT.rawValue): .bisect
case Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX.rawValue),
Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE.rawValue): .applyMailbox
// Every rebase flavour reads as one pause: the popover says "a rebase is in progress" and the
// engine holds, and no consumer of either is finer-grained than that.
default: .rebase
}
}
/// **Which paths differ between HEAD and the working tree**, `.gitignore` respected.
///
/// This is the commit's *condition* "its commit condition is the *tree*, not the snapshot
/// diff, so a stray-only window commits rather than leaving the tree dirty" (06 Commit
/// messages Non-snapshot files commit too) and it is also the composer's second input, which
/// is why it comes back as values rather than as a count.
///
/// ### Why it stages into the index rather than reading `git_status`
///
/// Because of one clause: "**A folder move is not a deletion**: items match by id across the
/// whole board so a moved card attributes by its stamp like any changed file" (06). Rename
/// detection is a *similarity* pass over a diff, and libgit2 only runs it where both ends are in
/// one diff `git_status`' `RENAMES_INDEX_TO_WORKDIR` finds a rename made **after** staging, and
/// a plain `mv` in a working tree nobody has staged is simply a delete beside an add. Measured,
/// not assumed: the first cut of this function used status with every rename flag set, and a
/// re-stamped agent move still demoted to `Lanework External`.
///
/// So the diff is taken where the pairing can be seen: everything the working tree says is staged
/// into the **in-memory** index (`git_index_add_all` full `git add -A` semantics, ignores
/// respected, deletions dropped), HEAD's tree is diffed against it, and `git_diff_find_similar`
/// pairs the ends. **Nothing is written**: the index file on disk is untouched, which is what
/// keeps this a read, and the index object is reset to HEAD on the way out so a caller that goes
/// on to stage a *subset* starts from a known base rather than from everything.
/// **`nil` means the survey could not be taken**, which is emphatically not the same answer as
/// "nothing changed" and must never be flattened into it.
///
/// Staging writes blobs into the object store, so a repository whose `.git/objects` has become
/// unwritable fails *here* rather than at the commit and a version of this that shrugged and
/// returned no paths would report a clean tree, no-op silently, and let history stop advancing
/// with nothing on the banner strip. That is precisely the case 06 separates from contention:
/// "Genuine commit failures disk full, repo corruption are different: files stay safe on disk
/// but history stops advancing; surfaced per 02-architecture.md Write-failure surfacing."
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
/// moved from `git_status` to staging.)
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else { return nil }
defer { git_index_free(index) }
return changedPaths(in: repository, index: index)
}
/// The survey, with "could not look" folded into "nothing to do" for the callers that have no
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
/// clean-tree assertions.
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
surveyChangedPaths(at: boardRoot) ?? []
}
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? {
var pathspec = git_strarray()
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
return nil
}
defer { resetIndexToHead(index, in: repository) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
var headTree: OpaquePointer?
if let parent { git_commit_tree(&headTree, parent) }
defer { headTree.map(git_tree_free) }
var diff: OpaquePointer?
var options = git_diff_options()
guard git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)) == 0,
git_diff_tree_to_index(&diff, repository, headTree, index, &options) == 0,
let diff else { return nil }
defer { git_diff_free(diff) }
var findOptions = git_diff_find_options()
if git_diff_find_options_init(&findOptions, UInt32(GIT_DIFF_FIND_OPTIONS_VERSION)) == 0 {
findOptions.flags = GIT_DIFF_FIND_RENAMES.rawValue
// Best-effort: a diff too large for the similarity pass simply reports the unpaired
// shape, which demotes the window to the generic external author the safe direction,
// and the one the guide's re-stamping advice already covers.
_ = git_diff_find_similar(diff, &findOptions)
}
var found: [String: GitChangedPath] = [:]
func record(_ path: String?, isDeletion: Bool, isRename: Bool) {
guard let path, !path.isEmpty else { return }
let existing = found[path]
found[path] = GitChangedPath(
path: path,
// Present wins where two deltas disagree: staging asks "is it there now", and the
// `modified-by` demotion must not fire for a file the window ends with.
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
isRename: (existing?.isRename ?? false) || isRename
)
}
for position in 0..<git_diff_num_deltas(diff) {
guard let delta = git_diff_get_delta(diff, position)?.pointee else { continue }
switch delta.status {
case GIT_DELTA_DELETED:
record(string(delta.old_file.path), isDeletion: true, isRename: false)
case GIT_DELTA_RENAMED:
// Both ends, and neither is a deletion the window may be demoted by: the departure
// has to leave the index and the arrival has to enter it.
record(string(delta.old_file.path), isDeletion: true, isRename: true)
record(string(delta.new_file.path), isDeletion: false, isRename: true)
default:
record(string(delta.new_file.path), isDeletion: false, isRename: false)
}
}
return found.values.sorted { $0.path < $1.path }
}
/// Puts the index object back to exactly HEAD's tree the base every per-class staging works up
/// from, and what makes the discovery pass above a read.
///
/// On an unborn HEAD that is an empty index, which is the same statement with no tree to say it
/// with. Note this is the *in-memory* index: nothing here calls `git_index_write`, so a caller
/// that abandons the flush leaves the file on disk exactly as it found it, staged changes of
/// another writer's included.
private static func resetIndexToHead(_ index: OpaquePointer, in repository: OpaquePointer) {
guard let parent = headCommit(of: repository) else {
git_index_clear(index)
return
}
defer { git_commit_free(parent) }
var tree: OpaquePointer?
guard git_commit_tree(&tree, parent) == 0, let tree else { return }
defer { git_tree_free(tree) }
git_index_read_tree(index, tree)
}
// MARK: - Committing
/// **Stages and commits each plan in turn, with explicit signatures.**
///
/// The plans are committed **in the order given** and each is a whole commit of its own which
/// is how the two-commit split ("A debounce window containing both kinds is split into two
/// commits, never mixed") and the heal's third class (ruled 2026-07-29) become one mechanism
/// rather than three code paths.
///
/// A plan whose staging produces the tree HEAD already has is **skipped, not committed**: an
/// empty commit says nothing and would make `git log` a record of the debounce timer rather than
/// of the board. That is also the clean-tree no-op, arrived at without a special case.
///
/// - Parameter allowRootCommit: whether an unborn HEAD may take its root commit here. The caller
/// passes `true`; it exists so the "first settled change on an adopted unborn repo commits the
/// whole tree as *Initial board state*" rule stays a decision the *planner* made and is not
/// re-derived down here.
nonisolated static func perform(
at boardRoot: URL,
commits: [PlannedCommit],
allowRootCommit: Bool = true
) -> GitCommitOutcome {
_ = startUp
guard !commits.isEmpty else { return .nothingToCommit }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_repository_free(repository) }
// Re-checked here, inside the same handle that is about to write, rather than trusted from
// the caller's earlier `reading(at:)`: between the two a terminal can have started a rebase,
// and 06's rule is that the check runs "again before every flush".
if git_repository_head_unborn(repository) == 1 {
guard allowRootCommit else { return .nothingToCommit }
} else if git_repository_head_detached(repository) == 1 {
return .held(.detachedHead)
}
if let pause = pause(of: repository) { return .held(pause) }
if isIndexLocked(gitDirectory: gitDirectory(of: repository)) { return .locked }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else {
return failure(lastErrorMessage())
}
defer { git_index_free(index) }
// **Every split starts from HEAD, not from whatever the index happened to hold.** Each plan
// below writes the *whole* index as a tree, so a change another writer had staged but not
// committed would otherwise ride into whichever commit came first silently attributing it
// to that class. Resetting makes each commit exactly HEAD plus the paths its own class
// staged, which is what "split into two commits, never mixed" has to mean. The staged change
// is not lost: it is a changed path like any other and is classified and committed on its
// own terms.
resetIndexToHead(index, in: repository)
var landed: [String] = []
for plan in commits {
switch commit(plan, in: repository, index: index) {
case let .landed(oid):
landed.append(oid)
case .skipped:
continue
case let .stopped(outcome):
// Whatever landed before the failure stays landed those commits are real, and
// reporting them is what lets the caller clear the suspension for the half that
// worked while retrying the rest on the next debounce.
if case let .failed(reason) = outcome, !landed.isEmpty {
logger.error("commit split failed partway: \(reason.message, privacy: .public)")
}
return outcome
}
}
return landed.isEmpty ? .nothingToCommit : .committed(landed)
}
/// What one plan did.
private enum CommitStep {
case landed(String)
/// Its staging produced the tree HEAD already has an empty commit, deliberately not made.
case skipped
case stopped(GitCommitOutcome)
}
/// One plan: stage its paths, write the tree, and create the commit if the tree is new.
private static func commit(
_ plan: PlannedCommit,
in repository: OpaquePointer,
index: OpaquePointer
) -> CommitStep {
// **Path by path, never a pathspec.** `git_index_add_all` would take a glob, and a card
// titled with a `[` in its folder name is a real board; exact `add`/`remove` calls also make
// the stage-around exact an excluded folder is one this loop never mentions, rather than
// one a matcher has to be trusted to miss.
for path in plan.paths {
let exists = FileManager.default.fileExists(
atPath: workdir(of: repository).appendingPathComponent(path).path
)
let status = exists
? git_index_add_bypath(index, path)
: git_index_remove_bypath(index, path)
// `GIT_ENOTFOUND` on a removal is a path the index never had an untracked file that
// vanished inside the window. Nothing to stage and nothing wrong.
guard status == 0 || (!exists && status == GIT_ENOTFOUND.rawValue) else {
return .stopped(classify(status))
}
}
var treeOID = git_oid()
guard git_index_write_tree(&treeOID, index) == 0 else { return .stopped(classify(lastErrorCode())) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
if let parent, let headTree = treeIdentity(of: parent), equal(headTree, treeOID) {
return .skipped
}
// The index is persisted **before** the commit, deliberately: this is the call `index.lock`
// bites on, and failing here leaves an unreferenced tree object (garbage libgit2 collects)
// rather than a commit whose index nobody can see.
guard git_index_write(index) == 0 else { return .stopped(classify(lastErrorCode())) }
var tree: OpaquePointer?
guard git_tree_lookup(&tree, repository, &treeOID) == 0, let tree else {
return .stopped(classify(lastErrorCode()))
}
defer { git_tree_free(tree) }
guard let author = signature(plan.author), let committer = signature(plan.committer) else {
return .stopped(failure(lastErrorMessage()))
}
defer {
git_signature_free(author)
git_signature_free(committer)
}
var commitOID = git_oid()
var parents: [OpaquePointer?] = parent.map { [$0] } ?? []
let status = parents.withUnsafeMutableBufferPointer { buffer in
git_commit_create(
&commitOID,
repository,
// "HEAD" rather than a branch name: on an unborn HEAD this creates the branch the
// symbolic ref names, and on a born one it advances whatever branch is checked out
// one call for the root commit and every commit after it.
"HEAD",
author,
committer,
nil,
plan.message,
tree,
buffer.count,
buffer.baseAddress
)
}
guard status == 0 else { return .stopped(classify(status)) }
return .landed(hex(commitOID))
}
// MARK: - Identity
/// **Where the user's identity comes from, resolved at commit time** (06 Interaction with
/// external writers "Where the user's git identity comes from") repo-local `.git/config`
/// when present, the derived default otherwise.
///
/// **The one place that order lives.** Until this card, `GitRepository.applyIdentity` also
/// encoded it, by *materializing* the resolved identity into the new repository's config so that
/// libgit2's signature-less commit would find something; that was an explicit interim and it is
/// gone. Nothing writes `user.name`/`user.email` any more: the popover's identity fields (a
/// later card) will, because there "the setting *is* the file", and an app that wrote the file
/// on its own could never tell its own default from the user's choice.
///
/// The `.git` directory is libgit2's answer rather than `boardRoot/.git`, so a board whose
/// `.git` is a *file* (a linked worktree `BoardGitMode` counts those as git mode) resolves its
/// real config instead of trying to parse a pointer.
nonisolated static func userIdentity(at boardRoot: URL) -> GitIdentity {
_ = startUp
guard let repository = open(boardRoot) else {
return GitIdentity.resolve(repoLocal: (nil, nil), derived: .derivedDefault())
}
defer { git_repository_free(repository) }
return GitIdentity.resolve(
repoLocal: GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository)),
derived: .derivedDefault()
)
}
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
var signature: UnsafeMutablePointer<git_signature>?
let now = Date()
let status = git_signature_new(
&signature,
identity.name,
identity.email,
git_time_t(now.timeIntervalSince1970),
Int32(TimeZone.current.secondsFromGMT(for: now) / 60)
)
return status == 0 ? signature : nil
}
// MARK: - index.lock
/// Whether `.git/index.lock` is there right now.
///
/// **Never removed, whatever its age** (06 Interaction with external writers): "a crashed
/// writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's
/// own leftovers". The pathfinder deleted locks older than ten minutes; that heuristic is
/// deliberately not carried over it is precisely a mutation of repo state the app did not
/// create.
nonisolated static func isIndexLocked(at boardRoot: URL) -> Bool {
_ = startUp
guard let repository = open(boardRoot) else { return false }
defer { git_repository_free(repository) }
return isIndexLocked(gitDirectory: gitDirectory(of: repository))
}
private static func isIndexLocked(gitDirectory: URL) -> Bool {
FileManager.default.fileExists(atPath: gitDirectory.appendingPathComponent("index.lock").path)
}
// MARK: - Private plumbing
private static let operationName = "Recording this board's history"
private static func open(_ boardRoot: URL) -> OpaquePointer? {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
return repository
}
private static func gitDirectory(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
}
private static func workdir(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_workdir(repository)) ?? "", isDirectory: true)
}
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
return object
}
private static func treeIdentity(of commit: OpaquePointer) -> git_oid? {
guard let tree = git_commit_tree_id(commit) else { return nil }
return tree.pointee
}
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
var left = lhs
var right = rhs
return git_oid_cmp(&left, &right) == 0
}
private static func hex(_ oid: git_oid) -> String {
var value = oid
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&buffer, &value)
return String(cString: buffer)
}
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
pointer.map { String(cString: $0) }
}
/// libgit2's message for whatever just failed, or a shrug when it set none.
private static func lastErrorMessage() -> String {
guard let error = git_error_last(), let message = error.pointee.message else {
return "libgit2 reported no reason"
}
return String(cString: message)
}
private static func lastErrorCode() -> Int32 {
git_error_last() != nil ? GIT_ERROR.rawValue : GIT_ERROR.rawValue
}
/// Turns a libgit2 status into the outcome 06 gives it.
///
/// **`GIT_ELOCKED` is the whole reason this exists**: contention is "never an error", so it must
/// not travel the same road as a disk failure. Everything else is a genuine failure carrying
/// libgit2's own message.
private static func classify(_ status: Int32) -> GitCommitOutcome {
status == GIT_ELOCKED.rawValue ? .locked : failure(lastErrorMessage())
}
private static func failure(_ message: String) -> GitCommitOutcome {
.failed(GitOperationFailure(operation: operationName, message: message))
}
}
+6 -1
View File
@@ -99,7 +99,12 @@ public extension GitIdentity {
/// Characters an address part may carry, with everything else collapsed to `-`. Deliberately
/// conservative rather than RFC-complete: the input is a Mac account name and a Bonjour host
/// name, and the only job is that libgit2 accepts the signature and a git client renders it.
private static func addressComponent(_ raw: String, fallback: String) -> String {
///
/// Shared with `CommitAttribution.agentIdentity(named:)` a `modified-by` stamp is arbitrary
/// self-reported text and needs exactly this treatment to become an address local part
/// ("display name verbatim, email local part slugified", 06-history-undo.md). One slug rule for
/// both, so a name that is safe in a derived default cannot be unsafe in an agent's address.
static func addressComponent(_ raw: String, fallback: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
let mapped = String(
String.UnicodeScalarView(
+46 -53
View File
@@ -115,60 +115,53 @@ enum GitRepository {
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
}
// A **fresh handle** for the staging and the commit, so nothing reads HEAD through a
// repository object that predates the symbolic ref just written: libgit2 caches refs per
// repository, and the whole point of writing that file was to decide where the first commit
// lands. The creating handle is dropped above.
let repository: Repository
do {
repository = try Repository.open(at: boardRoot)
} catch {
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
}
// **The root commit goes through the same signature-capable path every later commit does**
// (`GitCommitOperation`), which is what retired this method's config materialization.
//
// Until the auto-commit card there was no way to hand libgit2 a signature through SwiftGitX
// `commit(message:)` leaves `author`/`committer` null and libgit2 falls back to
// `git_signature_default`, which reads a merged config ladder the sandbox cannot see so
// add-git wrote `user.name`/`user.email` into the fresh repository's own config to give that
// fallback something to find. That was an explicit interim, and it is gone: **nothing in the
// app writes those keys any more.** The identity resolves at commit time, in one place
// (`GitCommitOperation.userIdentity(at:)`), repo-local config winning over the derived
// default exactly as 06 states and a repository the app created now looks like one `git
// init` made, with no opinion of ours baked into its config. The popover's identity fields
// (a later card) are what will write that file, because there "the setting *is* the file".
//
// Every path `git status` reports is staged full `git add -A` semantics, `.gitignore`
// respected which is what "commits the whole tree" means: the board's files, the agent
// guide, strays and all (06 Commit messages: "the committer stages the whole board root").
let identity = GitCommitOperation.userIdentity(at: boardRoot)
let outcome = GitCommitOperation.perform(
at: boardRoot,
commits: [PlannedCommit(
paths: GitCommitOperation.changedPaths(at: boardRoot).map(\.path),
message: initialCommitSubject,
author: identity,
committer: identity
)]
)
applyIdentity(to: repository, gitDirectory: gitDirectory)
do {
// An empty pathspec passed to `git_index_add_all` (via `add(paths:)`) matches every path
// in the working tree full `git add -A` semantics in one step, `.gitignore` respected
// which is what "commits the whole tree" means: the board's files, the agent guide,
// strays and all (06 Commit messages: "the committer stages the whole board root").
try repository.add(paths: [])
_ = try repository.commit(message: initialCommitSubject)
} catch {
logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(reason(error), privacy: .public)")
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
}
return .success(branchName(at: boardRoot) ?? initialBranchName)
}
/// Gives the repository a commit identity **only when it has none** (06-history-undo.md
/// Interaction with external writers "Where the user's git identity comes from").
///
/// `GitIdentity` resolves what the identity *is*: repo-local config when present, the derived
/// default otherwise. What this method adds is the mechanism writing the resolved identity
/// into the repository's own config so libgit2's default signature resolves to it.
///
/// **That write is a mechanism, not a design decision, and it is the narrowest one available.**
/// SwiftGitX 0.4.0's `commit(message:)` takes no signature (its `CommitOptions` leaves
/// `author`/`committer` null, so libgit2 falls back to `git_signature_default`, which fails
/// outright in a sandbox with no readable config). Every board this runs on is one the app
/// created milliseconds earlier, whose config the app itself wrote, and the keys are only ever
/// *added* a config that already names an identity is left exactly as it was, which is the
/// adopted-repo promise. The auto-commit card needs per-commit authorship anyway (foreign
/// changes commit as `Lanework External`, `modified-by` windows as the agent), so it must reach
/// a signature-capable commit path regardless; when it does, this materialization goes with it.
private static func applyIdentity(to repository: Repository, gitDirectory: URL) {
let configured = GitConfigFile.identity(inGitDirectory: gitDirectory)
guard configured.name == nil || configured.email == nil else { return }
let identity = GitIdentity.resolve(repoLocal: configured, derived: .derivedDefault())
if configured.name == nil {
try? repository.config.set("user.name", to: identity.name)
}
if configured.email == nil {
try? repository.config.set("user.email", to: identity.email)
switch outcome {
case .committed:
return .success(branchName(at: boardRoot) ?? initialBranchName)
case .nothingToCommit:
// A board with no files at all `git init` on an empty folder. The repository exists,
// which is what add-git promised; the first settled change takes the root commit through
// the ordinary engine (06 Rules Abnormal repo states: an unborn HEAD "is normal git
// mode"), and the branch line has a name to show either way.
return .success(branchName(at: boardRoot) ?? initialBranchName)
case .locked:
return .failure(GitOperationFailure(
operation: operation,
message: "another program is using this repository's index"
))
case let .held(pause):
return .failure(GitOperationFailure(operation: operation, message: pause.explanation))
case let .failed(failure):
logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(failure.message, privacy: .public)")
return .failure(GitOperationFailure(operation: operation, message: failure.message))
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
// MARK: - HistoryCommitSeam
/// **The three places the auto-committer touches the store's write and reload paths**
/// (06-history-undo.md Rules Auto-commit, Flush-before-overwrite).
///
/// ### Why a struct of closures rather than a reference to the committer
///
/// `BoardStore` lives in the live store and must not learn what a repository is: the free tier
/// composes no `HistoryStore`, so the engine has to be *structurally* unreachable there rather than
/// switched off, and a store holding an optional committer would be a store that knows about git.
/// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets`
/// and `history` already take, and it keeps the three orderings before the write, after the
/// bracket, after the landing stated in one type instead of three properties that could drift.
///
/// It is also what makes the ordering testable without a repository: a test binds a seam that records
/// its calls and asserts that a write flushed before it landed, exactly as `CloseFlushCoordinator`'s
/// closures do for the close sequence.
@MainActor
public struct HistoryCommitSeam {
/// **Before an app write** flush the pending auto-commit if this write could overwrite an
/// external version that is not in history yet, so "both versions exist as commits" holds.
///
/// Synchronous because `performWrite` is: an ordering guarantee *before* a synchronous write can
/// only be kept synchronously. See `GitAutoCommitter.noteWillWrite()` for the gate that keeps it
/// rare and for the costs it carries.
public var willWrite: () -> Void
/// **After a write bracket closes** harvest the bracket's receipts and arm the debounce.
///
/// The harvest is why this is a signal of its own: receipts are consumed by the landing reload
/// that classifies them, and this is the last moment they still describe a completed write
/// (`EchoLedger.outstandingEntries`).
public var writeBracketDidClose: () -> Void
/// **After a reload lands** arm the debounce, carrying whether the reload revealed anything the
/// ledger did not vouch for.
public var reloadDidLand: (_ sawForeignChange: Bool) -> Void
public init(
willWrite: @escaping () -> Void,
writeBracketDidClose: @escaping () -> Void,
reloadDidLand: @escaping (Bool) -> Void
) {
self.willWrite = willWrite
self.writeBracketDidClose = writeBracketDidClose
self.reloadDidLand = reloadDidLand
}
/// The seam a session binds for a board that has a committer the one production composition,
/// kept beside the type so no call site spells the three wirings out.
public static func binding(to committer: GitAutoCommitter) -> HistoryCommitSeam {
HistoryCommitSeam(
willWrite: { [weak committer] in committer?.noteWillWrite() },
writeBracketDidClose: { [weak committer] in committer?.noteWriteBracketClosed() },
reloadDidLand: { [weak committer] saw in committer?.noteReloadLanded(sawForeignChange: saw) }
)
}
}
+67 -3
View File
@@ -64,11 +64,59 @@ public final class HistoryStore {
/// 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 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)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode) {
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)
}
}
/// **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()
}
/// Stops the committer the session's teardown, so a closed board's debounce cannot fire against
/// a store that has gone.
public func stopAutoCommit() {
committer?.stop()
}
/// **The tier gate and the open-time detection, in one line** (12-editions.md The provider
@@ -83,11 +131,17 @@ public final class HistoryStore {
/// **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).
public static func compose(boardRoot: URL, tier: Tier) -> HistoryStore? {
///
/// - 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)
return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger)
}
// MARK: - Add git
@@ -125,6 +179,16 @@ public final class HistoryStore {
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()
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
return true
case .failure(let failure):
+49 -1
View File
@@ -432,6 +432,18 @@ public final class BoardStore: HealHost {
@ObservationIgnored
public weak var history: (any HistoryProviding)?
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md Rules
/// Auto-commit), or `nil` on every board there is no committer for which is every free-tier
/// board and every Pro board without a repository at its root.
///
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
/// did before this milestone which is what makes the free tier's inert posture structural
/// rather than conditional.
@ObservationIgnored
public var commitSeam: HistoryCommitSeam?
// MARK: Reload machinery
/// Monotonic id of the most recently *started* reload and therefore also the number of tree
@@ -713,6 +725,13 @@ public final class BoardStore: HealHost {
facts.lockBefore = readOnlyLock
facts.breakageBefore = reloadFailure
// **Whether this reload revealed anything the app does not vouch for** the one bit the
// auto-committer's flush-before-overwrite gate turns on (06 Rules Flush-before-overwrite).
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
// the app certainly did not write, and the safe direction is to let the next app write commit
// what is there before overwriting it.
var sawForeignChange = false
switch outcome {
case let .success(result):
// **What changed, who changed it, and what it cost the cursor** all three computed
@@ -744,6 +763,7 @@ public final class BoardStore: HealHost {
includingTrash: shownTrash
)
facts.diff = verdicts.foreign
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
// "deleted *externally*", which would be a lie about an app-mediated delete whose
// own command already chose a successor (04-interactions.md The map's rule) and
@@ -854,6 +874,7 @@ public final class BoardStore: HealHost {
if endsWholesaleOperation, readOnlyLock == nil {
readOnlyLock = .bracketedReloadFailed
}
sawForeignChange = true
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
}
@@ -864,6 +885,18 @@ public final class BoardStore: HealHost {
facts.lockAfter = readOnlyLock
facts.breakageAfter = reloadFailure
announce(BoardAnnouncer.speech(for: facts))
// **The auto-commit debounce, armed by every landing** (06 Rules Auto-commit;
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
// any change and get auto-committed on the same debounce").
//
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
// over, so the committer never races the loader for the same files. **Unconditional on what
// changed**, equally deliberately a reload lands whether or not the snapshot moved, and the
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
// strays or only `CLAUDE.md` still commits (06 Commit messages Non-snapshot files commit
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
commitSeam?.reloadDidLand(sawForeignChange)
}
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
@@ -1118,10 +1151,25 @@ public final class BoardStore: HealHost {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
// **Flush-before-overwrite** (06-history-undo.md Rules), before the bracket rather than
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
// the window holds a change the app does not vouch for see `GitAutoCommitter.noteWillWrite`
// for the gate, and for the two costs it is recorded as carrying.
commitSeam?.willWrite()
watcherBrackets?.begin()
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
//
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
// the last moment they still describe a completed write nothing has classified yet
// (`EchoLedger.outstandingEntries`). A partway failure harvests too bytes that reached disk
// are bytes the next commit will carry, whoever they belong to.
defer {
watcherBrackets?.end()
commitSeam?.writeBracketDidClose()
}
// **The receipt seam** (02-architecture.md Components EchoLedger). Binding the ledger
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
+20
View File
@@ -268,6 +268,26 @@ public final class EchoLedger: Sendable {
receipts.withLock { $0[path]?.receipt }
}
/// **Every receipt the ledger holds right now, with its heal mark read, never consumed.**
///
/// Pro's auto-committer's one call (`GitAutoCommitter.harvest`), and it has to be a copy rather
/// than a read at commit time for an ordering reason worth stating here: receipts are *consumed*
/// by the landing reload that classifies them ("one write, one echo"), and the committer asks its
/// question a debounce later by which time the receipt for the user's own card edit is long
/// gone, and reading the live ledger would attribute the user's own work to `Lanework External`.
/// So the committer copies at the close of each write bracket, when a receipt describes a
/// completed write and nothing has yet had a chance to retire it, and re-applies the satisfaction
/// rule against disk itself (`CommitAttribution`).
///
/// Nothing is retired here, which is what makes this safe to call on every bracket: the
/// announcer's consumption still decides what speaks, and the committer's copy still decides what
/// each commit is authored by.
func outstandingEntries() -> [String: HarvestedReceipt] {
receipts.withLock { store in
store.mapValues { HarvestedReceipt(receipt: $0.receipt, isHeal: $0.isHeal) }
}
}
/// Whether the receipt at this path is heal-marked `false` for a path with no receipt at all,
/// which is the same shrug every other read here gives an unknown path.
public func isHeal(at url: URL) -> Bool {
+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
}
+13
View File
@@ -71,6 +71,15 @@ public final class CardBodyPresentation {
/// than by three call sites remembering.
public var flushEdits: (() -> Void)?
/// **Opens an Edit session** the mirror of `flushEdits`, and here for its reason exactly: this
/// is the type every path that *enters* Edit already holds, so attaching the announcement to the
/// flip makes "always" true by construction rather than by three call sites remembering.
///
/// Filled in by the window with its edit session's `beginEditSession()`. Its consumer is pro-m1's
/// auto-committer, which stages around the card's folder for as long as the session stands
/// (06-history-undo.md Rules Auto-commit).
public var beginEdits: (() -> Void)?
/// Whether the opening rule has already run for this window.
///
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
@@ -87,6 +96,9 @@ public final class CardBodyPresentation {
guard !hasOpened else { return mode }
hasOpened = true
mode = CardBodyMode.opening(body: body)
// A card that opened straight into Edit because its body was empty is in a session exactly
// like one the user pressed E in, and the committer has to stage around it either way.
if mode == .edit { beginEdits?() }
return mode
}
@@ -111,6 +123,7 @@ public final class CardBodyPresentation {
guard newMode != mode else { return }
if mode == .edit { flushEdits?() }
mode = newMode
if newMode == .edit { beginEdits?() }
}
}