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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 Edit→Preview 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user