Files
lanework/Kanban/Git/CommitAttribution.swift
T
rzen 3c07c26fda 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
2026-07-31 14:10:55 -04:00

262 lines
13 KiB
Swift

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 <[email protected]>`.
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)
}
}