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?() }
}
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -61,7 +61,9 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **The first of that is built** — see "Git integration" below — and the rest is pro-m1 and pro-m2's remaining work; until it ships both tiers run the same native undo stack, the free one over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. Commits are authored from the repository's own `.git/config` when it names an identity, and otherwise from your macOS account name and machine (the popover's identity fields land with a later card). A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. Still ahead in pro-m1/m2: auto-commit with semantic messages, git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. Messages read "Update board" for now — the semantic message engine ("Move card 'Fix login' to Doing") is the next card. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
## Development
+17
View File
@@ -35,6 +35,22 @@ packages:
SwiftGitX:
url: https://github.com/ibrahimcetin/SwiftGitX
exactVersion: 0.4.0
# **The C library underneath SwiftGitX, named directly** (06-history-undo.md ▸ Interaction with
# external writers: "foreign changes are committed under the pinned synthetic author"). SwiftGitX
# 0.4.0's `commit(message:)` takes no signature — its `CommitOptions` leaves `author`/`committer`
# null and libgit2 falls back to `git_signature_default`, which reads a config the sandbox cannot
# see — so the auto-committer's per-commit authorship is unreachable through the wrapper. It
# reaches `git_commit_create` itself (`GitCommitOperation`), which needs the same vendored module
# SwiftGitX imports.
#
# **The same package SwiftGitX already resolves**, pinned to the version SwiftGitX pins exactly
# (`.package(url: …/libgit2.git, exact: "1.9.2")`): this adds no second copy of libgit2 to the
# binary and no second resolution to argue with — it makes a module the app already links
# *importable*. A drift between these two pins is a resolver error at build time, which is the
# loudest place for it to be.
libgit2:
url: https://github.com/ibrahimcetin/libgit2.git
exactVersion: 1.9.2
settings:
base:
@@ -100,6 +116,7 @@ targets:
product: Markdown
- package: IndieAbout
- package: SwiftGitX
- package: libgit2
postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info