Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+20
View File
@@ -108,6 +108,19 @@ public enum CommitAttribution {
/// and the generic external author".
public static let agentEmailDomain = "agents.lanework.invalid"
/// **Who a heal commit is by** (06 Commit messages Healing mutations commit separately, ruled
/// 2026-07-31 "the third pinned synthetic, joining Lanework External and the agent-slug family;
/// strings are API"):
///
/// > a heal is a third origin not the user's gesture, not a foreign writer and the separation
/// > exists for audit, so the trail filters by author like every origin; the committer stays the
/// > user (the recorded-by convention above).
///
/// It replaced authoring heals as the user, which made the separate commit filterable only by
/// message shape and the shape vocabulary deliberately never says "healed".
public static let integrityAuthorName = "Lanework Integrity"
public static let integrityAuthorEmail = "[email protected]"
/// 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"
@@ -116,6 +129,13 @@ public enum CommitAttribution {
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
}
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
/// identity, every time "every commit the app makes, foreign-authored included, records the
/// user's app as its committer" (06).
public static var integrityIdentity: GitIdentity {
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
}
/// **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)".
+20 -1
View File
@@ -75,6 +75,23 @@ public struct CommitMessageRequest: Sendable {
/// be read, or has been deleted.
public let agentGuideText: String?
/// **When each of this commit's comments was created** keyed by the comment folder's
/// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it.
///
/// The second value on this struct that a *file* has to be read for, and it is here for
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically by the
/// comments' own `created`, folder name on ties" (06 Rules Auto-commit, blessed 2026-07-31),
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
/// function of values.
///
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
/// at all each is simply absent here and sorts after its dated siblings in folder-name order,
/// which is `CommentThread.sorted`'s own fallback for the same field.
public let commentTimestamps: [String: Date]
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
@@ -82,7 +99,8 @@ public struct CommitMessageRequest: Sendable {
isRootCommit: Bool,
snapshot: BoardModel?,
previousSnapshot: BoardModel? = nil,
agentGuideText: String? = nil
agentGuideText: String? = nil,
commentTimestamps: [String: Date] = [:]
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
@@ -91,6 +109,7 @@ public struct CommitMessageRequest: Sendable {
self.snapshot = snapshot
self.previousSnapshot = previousSnapshot
self.agentGuideText = agentGuideText
self.commentTimestamps = commentTimestamps
}
}
+187 -23
View File
@@ -83,6 +83,11 @@ enum CommitMessageEngine {
/// container with no identity of its own (`BoardModel.trash`).
static let trashDestination = "the trash"
/// What a key's old (or new) side reads as when the frontmatter simply did not carry it the
/// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) 42"
/// says *added* without a second sentence shape for it.
static let absentValuePlaceholder = "(none)"
// MARK: - Entry point
/// One request in, one whole message out a subject, and a body when there is more to say.
@@ -242,8 +247,12 @@ enum CommitMessageEngine {
previous: previous.document,
current: current.document,
noun: "board",
// The trio's board phrasing is count-less and title-less ("Relabel board") there is one
// board and naming it adds nothing. The custom-key event is the one place 06 spells the
// board's title out ("Change custom key on board 'title'"), so it gets it separately.
itemTitle: nil,
kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard),
customKeyItemTitle: title(current.title),
kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard),
paths: paths
)
return events
@@ -380,7 +389,8 @@ enum CommitMessageEngine {
current: new.document,
noun: "lane",
itemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .updateLane),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane),
paths: paths
)
}
@@ -637,7 +647,8 @@ enum CommitMessageEngine {
current: new.card.document,
noun: "card",
itemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .updateCard),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard),
paths: paths
)
return events
@@ -684,9 +695,19 @@ enum CommitMessageEngine {
/// **The full schema-1 surface, plus the reserved trio, deliberately** (06 The external gap,
/// closed): "label, assignee, and due changes compose even though 01-storage-format.md reserves
/// those keys out of this version's UI external writers are exactly who touches them. A change
/// to any other unmodeled or custom key composes a named generic ('Update card 'X'') **never a
/// board-level shrug when the touched item is identifiable**."
/// those keys out of this version's UI external writers are exactly who touches them."
///
/// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31
/// "first lines self-describe; generics are a last resort, kept very rare"):
///
/// > **"Change custom key on card 'X'"** (board and lane likewise "Change custom key on board
/// > 'title'"; several keys fold plural), the body naming each key with its old new values.
///
/// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only
/// "for a change in a known file that is neither a vocabulary event nor a key change a shape
/// that should almost never occur". Nothing composes that shape today: a known file whose diff is
/// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary
/// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s.
///
/// Read off `unknownFields`, which is exactly "every key the schema does not own" so the
/// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`,
@@ -701,7 +722,8 @@ enum CommitMessageEngine {
current: FrontmatterDocument,
noun: String,
itemTitle: String?,
kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind),
customKeyItemTitle: String,
kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
paths: [String]
) -> [Event] {
let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
@@ -726,11 +748,24 @@ enum CommitMessageEngine {
if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) }
if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) }
if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) }
// Everything else the schema does not own one named generic for the item, however many
// custom keys an agent touched in the same window.
// **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The
// *item* is still what the subject names one event however many keys moved but the verb
// now describes the change instead of shrugging at it, and the keys themselves ride the body.
let trio: Set<String> = [Keys.labels, Keys.assignees, Keys.due]
if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) {
events.append(event(kinds.generic, "Update"))
let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted()
if !keys.isEmpty {
let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys"
events.append(Event(
kind: kinds.customKey,
subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))",
bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))",
detail: keys
.map { "\($0): \(before[$0] ?? absentValuePlaceholder)\(after[$0] ?? absentValuePlaceholder)" }
.joined(separator: "\n"),
destination: customKeyItemTitle,
paths: paths
))
}
return events
}
@@ -750,10 +785,17 @@ enum CommitMessageEngine {
/// lane is one event, not one line per file inside it);
/// - the departing end of a **rename**, which its arrival already speaks for the loose-file
/// relocation, the remint, a displaced squatter.
///
/// **One path shape is not a generic** (06 Commit messages Vocabulary, "Replace added
/// 2026-07-31"): "a changed file under a card's `attachments/` with an unchanged listing is a
/// content replacement, named from the path alone: 'Replace attachment 'photo.png' card 'X'',
/// never the anonymous path generic". An unchanged listing is exactly "no model event claimed
/// this path", so the rule needs no second question of the snapshot see `replacedAttachment`.
private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] {
let claimed = Set(model.flatMap(\.paths))
var comments: [String: CommentGroup] = [:]
var events: [Event] = []
lazy var cardTitles = cardTitlesByPath(request)
for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) {
let path = changed.path
@@ -763,7 +805,11 @@ enum CommitMessageEngine {
// schema): a trashed card's thread lives under `.trash/`, which the model-silence rule
// would otherwise swallow whole.
if let comment = CommentPath.classify(path) {
comments[CommentGroup.key(comment), default: CommentGroup(comment: comment)].add(changed)
let folder = commentFolder(of: path) ?? comment.cardPath
comments[
CommentGroup.key(comment),
default: CommentGroup(comment: comment, folder: folder)
].add(changed)
continue
}
guard !Paths.isModelSilent(path) else { continue }
@@ -778,6 +824,21 @@ enum CommitMessageEngine {
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
continue
}
// **Replace**, before the anonymous generic the file is still listed, so the model had
// nothing to say and the path says it instead.
if let replaced = replacedAttachment(at: path) {
let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder
events.append(Event(
kind: .replaceFile,
subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))",
bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))",
destination: card,
paths: [path]
))
continue
}
events.append(Event(
kind: .updatePath,
subject: "Update \(quotedSubject(path))",
@@ -788,12 +849,33 @@ enum CommitMessageEngine {
return commentEvents(comments, model: model, request: request) + events
}
/// **A card attachment rewritten in place** the `Replace` shape, read from the path alone.
///
/// `<lane>/<card>/attachments/<file>`, and nothing else: an attachment one folder deeper is not a
/// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is
/// model-silent and never gets this far). Whether the *listing* changed is the caller's question,
/// already answered an added or removed file was claimed by its own Attach/Remove event before
/// this path was ever reached.
private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? {
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count == 4,
components[2] == Paths.attachmentsFolder,
let folder = cardFolder(of: path)
else { return nil }
return (folder, components[3])
}
// MARK: - The comment verb family
/// Every changed path inside **one comment folder**, gathered so a comment that had its
/// `index.md` and two attachments rewritten is one event rather than three.
private struct CommentGroup {
let comment: CommentPath
/// This comment's own folder, board-root-relative the key its `created` is looked up under
/// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads.
let folder: String
var paths: [String] = []
var hasArrival = false
var hasSurvivor = false
@@ -806,6 +888,11 @@ enum CommitMessageEngine {
}
}
/// The folder's last component the UUID a comment is named by, or `.draft`.
var folderName: String {
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
}
mutating func add(_ changed: GitChangedPath) {
paths.append(changed.path)
if changed.isArrival { hasArrival = true }
@@ -813,6 +900,29 @@ enum CommitMessageEngine {
}
}
/// **The comment folder a changed path sits in**, board-root-relative `<card>/comments/<uuid>`,
/// `<card>/comments/.trash/<uuid>` or `<card>/comments/.draft` or `nil` for a path that is not
/// inside a thread at all.
///
/// Shared with the flush that reads each of those folders' `created`
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
/// under and the key it is *looked up* by have one definition. Everything about where a thread
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
/// classification consumed.
static func commentFolder(of path: String) -> String? {
guard let comment = CommentPath.classify(path) else { return nil }
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
// `classify` has already guaranteed these counts `<lane>/<card>/comments/<entry>` for a
// comment or the draft, one deeper for `comments/.trash/<uuid>`.
let depth: Int
switch comment.kind {
case .comment, .draft: depth = 4
case .trashed: depth = 5
}
guard components.count >= depth else { return nil }
return components.prefix(depth).joined(separator: "/")
}
/// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
/// "foreign comment changes are described by **path shape** the 'Update agent guide (vN)'
/// mechanism: a changed path under `/comments/<uuid>/` composes 'Comment on card title' / 'Edit
@@ -850,7 +960,7 @@ enum CommitMessageEngine {
})
var events: [Event] = []
for key in groups.keys.sorted() {
for key in chronological(groups, timestamps: request.commentTimestamps) {
guard let group = groups[key], !group.paths.isEmpty else { continue }
guard !relocated.contains(group.comment.cardPath) else { continue }
let card = titles[group.comment.cardPath] ?? untitledPlaceholder
@@ -891,6 +1001,47 @@ enum CommitMessageEngine {
return events
}
/// **A commit's comment bullets sort chronologically** (06-history-undo.md Rules Auto-commit,
/// blessed 2026-07-31):
///
/// > "by the comments' own `created`, folder name on ties event order reads as the conversation
/// > did, never UUID-arbitrary."
///
/// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed
/// comments: `created` ascending, **the undated after the dated**, folder name on ties compared
/// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break
/// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on
/// screen; a commit describing the same thread in a different order would be the same events told
/// out of sequence.
///
/// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment
/// events almost always belong to one card the card-window session flush is that window by
/// construction and where they do not, "the order the events happened in" is still the honest
/// reading of a list of events; grouping by card first would sort a conversation by which lane its
/// card sits in. The group key breaks the last tie so the order is total, which keeps a message
/// reproducible rather than hash-ordered.
private static func chronological(
_ groups: [String: CommentGroup],
timestamps: [String: Date]
) -> [String] {
groups.keys.sorted { lhs, rhs in
guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs }
switch (timestamps[left.folder], timestamps[right.folder]) {
case let (leftDate?, rightDate?) where leftDate != rightDate:
return leftDate < rightDate
case (.some, .none):
return true
case (.none, .some):
return false
default:
break
}
let leftName = IntegrityRules.canonicalIdentity(left.folderName)
let rightName = IntegrityRules.canonicalIdentity(right.folderName)
return leftName == rightName ? lhs < rhs : leftName < rightName
}
}
/// The card folders this commit says moved, arrived or went the set a comment path checks
/// itself against before speaking.
private static func relocatedCardFolders(in model: [Event]) -> Set<String> {
@@ -962,17 +1113,17 @@ enum CommitMessageEngine {
/// The verb-plus-noun grouping that decides what folds with what **06's vocabulary, one case
/// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and
/// the board, Attach / Remove for attachment files, Repair for the remint, the trash pair, the
/// reserved metadata trio, and the two path shapes.
/// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash
/// pair, the reserved metadata trio, the custom-key change, and the two path shapes.
enum Kind: Hashable {
case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard
case relabelCard, assignCard, dueCard, updateCard
case attachFile, removeFile, reorderCards, repairDuplicate
case relabelCard, assignCard, dueCard, changeKeyCard
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
case relabelLane, assignLane, dueLane, updateLane, reorderLanes
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
case agentGuide, updatePath
@@ -1014,13 +1165,18 @@ enum CommitMessageEngine {
case .relabelCard: return "Relabel \(count) cards"
case .assignCard: return "Assign \(count) cards"
case .dueCard: return "Set due date on \(count) cards"
case .updateCard: return "Update \(count) cards"
case .changeKeyCard:
guard let destination else { return "Change custom keys on \(count) cards" }
return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))"
case .attachFile:
guard let destination else { return "Attach \(count) files" }
return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))"
case .removeFile:
guard let destination else { return "Remove \(count) files" }
return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))"
case .replaceFile:
guard let destination else { return "Replace \(count) attachments" }
return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))"
case .reorderCards:
guard let destination else { return "Reorder cards in \(count) lanes" }
return "Reorder cards in \(CommitMessageEngine.truncated(destination))"
@@ -1037,7 +1193,9 @@ enum CommitMessageEngine {
case .relabelLane: return "Relabel \(count) lanes"
case .assignLane: return "Assign \(count) lanes"
case .dueLane: return "Set due date on \(count) lanes"
case .updateLane: return "Update \(count) lanes"
case .changeKeyLane:
guard let destination else { return "Change custom keys on \(count) lanes" }
return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))"
// A board has one title, one description, one style and a lane reorder is a single
// whole-board event. None of these can actually recur; the switch stays exhaustive.
@@ -1048,7 +1206,11 @@ enum CommitMessageEngine {
case .relabelBoard: return "Relabel board"
case .assignBoard: return "Assign board"
case .dueBoard: return "Set due date on board"
case .updateBoard: return CommitMessageEngine.unnamedSubject
// One board, so a plural of *events* here is a plural of keys already folded into one
// event unreachable in practice, and count-less if it ever is.
case .changeKeyBoard:
guard let destination else { return "Change custom keys on board" }
return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))"
case .agentGuide: return "Update agent guide"
case .updatePath: return "Update \(count) files"
@@ -1108,7 +1270,9 @@ enum CommitMessageEngine {
/// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An
/// attachment is deliberately **not** here: the model carries attachment *names*, so an added
/// or removed file composes its own event, while a rewritten one same name, new bytes has
/// nothing in the snapshot to show for it and rightly composes "Update 'path'".
/// nothing in the snapshot to show for it and composes **Replace** off its path instead
/// (`replacedAttachment(at:)`, 06 Commit messages Vocabulary, added 2026-07-31 "never
/// the anonymous path generic").
/// Whether a path could make the *snapshot* differ at all every path the model speaks for,
/// plus attachments, whose names it carries.
///
+228 -9
View File
@@ -134,6 +134,45 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var currentSnapshot: (@MainActor () -> BoardModel?)?
/// **The reload pipeline settling** `BoardStore.awaitQuiescence()`, and `nil` on a storeless
/// committer.
///
/// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes
/// the *next* walk a walk that started after this flush's changes were on disk.
@ObservationIgnored
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
/// **Which generation the board `currentSnapshot` answers with is at**
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
///
/// `nil` the closure absent, or answering `nil` because the store has gone means there is no
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
/// committer.
@ObservationIgnored
public var snapshotGeneration: (@MainActor () -> Int?)?
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
/// it already has.
///
/// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the
/// flush awaits its covering snapshot and does not say what happens if that reload never lands. It
/// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by
/// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket
/// reload even if not one filesystem event was seen"), so the wait is short and certain in every
/// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths,
/// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries
/// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is
/// composed from the snapshot in hand one stale subject in a degraded configuration, against a
/// hang.
@ObservationIgnored
public var coveringSnapshotDeadline: Duration = .seconds(1)
/// How often the wait re-reads the generation. Polled rather than signalled for
/// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and
/// a continuation resumed by a reload that never lands has no way to.
@ObservationIgnored
public var coveringSnapshotPollInterval: Duration = .milliseconds(10)
/// **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:)`.
@@ -208,6 +247,23 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var holdsForeignChanges = false
/// **Whether an app write has closed with no reload landed since** the covering await's entry
/// gate (`awaitCoveringSnapshot()`).
///
/// Set at every write-bracket close and cleared by every landing, so it answers exactly "is
/// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board
/// would wait out the whole deadline for a reload nothing has any reason to schedule.
///
/// **The one corner it does not cover, recorded rather than discovered**: a reload that was
/// already *in flight* when the write bracket closed walked the pre-write tree, and its landing
/// clears this flag all the same the store's landing signal carries no such distinction
/// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk
/// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write
/// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and
/// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running).
@ObservationIgnored
private var holdsUncoveredWrites = false
/// Open **card-window 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
@@ -222,6 +278,10 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var isFlushing = false
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
@ObservationIgnored
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, ledger: EchoLedger) {
@@ -259,6 +319,9 @@ public final class GitAutoCommitter {
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
public func noteWriteBracketClosed() {
harvest()
// The snapshot the composer diffs is now known to be behind the tree until a reload lands
// see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`.
holdsUncoveredWrites = true
arm()
}
@@ -269,6 +332,7 @@ public final class GitAutoCommitter {
/// *file* at flush time, because this is one bit about a whole reload.
public func noteReloadLanded(sawForeignChange: Bool) {
if sawForeignChange { holdsForeignChanges = true }
holdsUncoveredWrites = false
arm()
}
@@ -307,7 +371,7 @@ public final class GitAutoCommitter {
public func noteWillWrite() {
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
@@ -398,9 +462,110 @@ public final class GitAutoCommitter {
/// 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 awaitCoveringSnapshot()
// **Queued behind an in-flight flush, never skipped** see `awaitFlushInFlight()`.
await awaitFlushInFlight()
await flush()
}
/// **Suspends until no flush is running** what makes `flushNow()` a promise rather than an
/// attempt (06-history-undo.md Rules Auto-commit: "nothing settled is ever left unsaved or
/// uncommitted by closing").
///
/// ### The bug this exists for
///
/// `flush()` skips when one is already running, which is exactly right for the **debounce** a
/// timer firing into a commit already in progress has nothing to add, and coalescing is the
/// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close
/// flush, the quit flush, the branch switch's pre-checkout flush and File Duplicate's: those
/// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is
/// empty, and a `return` gave them that answer while it was still full.
///
/// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The
/// close sequence releases each session's stage-around and then nudges the committer
/// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its
/// card-drain deadline before reaching `committerFlush`. With the two intervals both at two
/// seconds the debounce fired *into* the drain's last moments about half the time and the flush
/// it started had, in the worst case, planned its commit while the session's folder was still
/// staged around. So the in-flight flush committed nothing of the session, the close flush skipped
/// behind it, and teardown stopped the committer: the window's whole session was left uncommitted,
/// permanently, with no later flush anywhere that could have picked it up. Even in the benign
/// interleaving `closeBoard` returned and at quit, `applicationShouldTerminate` replied while
/// the commit was still detached work in flight.
///
/// ### The shape
///
/// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its
/// reason: this type is `@MainActor`, so there is no data race to exclude only a *suspension* to
/// wait out and the thing a caller wants is "tell me when it is over", which is what a resumed
/// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed
/// another on its way out cannot slip between the resume and the caller's own attempt.
private func awaitFlushInFlight() async {
while isFlushing {
await withCheckedContinuation { flushWaiters.append($0) }
}
}
/// Ends one flush and releases whoever was queued behind it. The single exit for both flushing
/// paths the debounced one and the synchronous flush-before-overwrite so a waiter can never be
/// left suspended by a path that forgot it.
private func endFlushing() {
isFlushing = false
let waiters = flushWaiters
flushWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
/// **The flush awaits the snapshot that covers it** (06-history-undo.md Rules Auto-commit,
/// ruled 2026-07-31).
///
/// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot
/// > generation covering its changed paths before the committer runs the commit's subject can
/// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the
/// > practical cushion, never the guarantee."
///
/// ### What "covering its changed paths" means to this store
///
/// A reload is a **whole tree walk** the store has no changed-path channel at all
/// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) so a walk that *started*
/// after this flush's writes were on disk covers every path they touched, by construction. There
/// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the
/// generation counter a sufficient answer rather than an approximation of one.
///
/// Two steps, in this order, are what turn it into a guarantee:
///
/// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing
/// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is
/// owed, which is the moment after which every walk is a walk that started later.
/// 2. **Wait for one generation.** The write bracket that produced these changes already
/// scheduled the reload that will supply it unconditionally, whether or not FSEvents said
/// anything (`FolderWatcher.endBracket`) so this is a bounded wait on work already in the
/// pipeline, not a hope.
///
/// ### Why only the explicit flush
///
/// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush,
/// File Duplicate's pending-work step, and the undo restore's. Those are the flushes that run
/// *because* something just finished, which is exactly when the snapshot can still be one walk
/// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds
/// after the later of them 06's own "practical cushion", doing the job it is enough for and
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
private func awaitCoveringSnapshot() async {
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
await awaitReloadQuiescence?()
// Re-read the gate: the quiescence may itself have been the covering landing.
guard holdsUncoveredWrites, let base = read() else { return }
let started = ContinuousClock.now
while let current = read(), current == base {
guard ContinuousClock.now - started < coveringSnapshotDeadline else {
Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand")
return
}
try? await Task.sleep(for: coveringSnapshotPollInterval)
}
}
/// 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) {
@@ -414,10 +579,13 @@ public final class GitAutoCommitter {
}
}
/// One flush. **Skipping when one is already running is the debounce's rule and only the
/// debounce's** an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`)
/// before it gets here, so this guard can only ever coalesce a timer.
private func flush() async {
guard !isFlushing else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
@@ -512,6 +680,7 @@ public final class GitAutoCommitter {
var previous: BoardModel?
var current: BoardModel?
var agentGuideText: String?
var commentTimestamps: [String: Date] = [:]
}
/// Reads the two snapshots and the guide's bytes the only impure step in the message path, kept
@@ -537,6 +706,12 @@ public final class GitAutoCommitter {
// card's title. So a comment-only window loads the current board and skips the materialization.
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
// **The chronology the bullets sort by** (06 Rules Auto-commit, blessed 2026-07-31) the
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
// the guide's bytes, for the guide's reason, and only for a window that names a comment at all.
if namesACard {
composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot)
}
guard touchesModel || namesACard else { return composition }
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
@@ -550,6 +725,42 @@ public final class GitAutoCommitter {
return composition
}
/// **When each comment this window touched was created**, keyed by its folder the chronology
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 Rules Auto-commit, blessed
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
///
/// One `index.md` per touched comment folder, read off the **working tree** which is the state
/// this commit is about to stage, and the only place a comment's own fields exist at all. A folder
/// this window *removed* (the close purge) has nothing left to read, and a comment whose
/// frontmatter does not parse or carries no `created` answers nothing either: all three are
/// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own
/// fallback for the same field. Nothing here is a defect and nothing is reported a commit
/// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept).
///
/// Internal rather than private so the composer's own suite can resolve the chronology exactly the
/// way a flush does, instead of hand-assembling a map the flush could never produce
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
nonisolated static func commentTimestamps(
for changed: [GitChangedPath],
boardRoot: URL
) -> [String: Date] {
var timestamps: [String: Date] = [:]
var seen: Set<String> = []
for path in changed {
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
else { continue }
let index = boardRoot
.appendingPathComponent(folder)
.appendingPathComponent(IntegrityRules.indexFileName)
guard let data = try? Data(contentsOf: index),
let document = try? BoardLoader.parseDocument(data, path: folder),
let created = document.created.value
else { continue }
timestamps[folder] = created
}
return timestamps
}
/// The three-way split turned into commits or, on an unborn HEAD, the one commit 06 fixes.
private nonisolated static func plan(
_ changed: [GitChangedPath],
@@ -571,7 +782,8 @@ public final class GitAutoCommitter {
isRootCommit: isRootCommit,
snapshot: composition.current,
previousSnapshot: composition.previous,
agentGuideText: composition.agentGuideText
agentGuideText: composition.agentGuideText,
commentTimestamps: composition.commentTimestamps
)
}
@@ -600,16 +812,23 @@ public final class GitAutoCommitter {
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.
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 Commit
// messages Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
// origin not the user's gesture, not a foreign writer and the separation exists for
// audit, so the trail filters by author like every origin". This authored heals as the
// *user* until that ruling, which left the separate commit filterable only by message
// shape and the shape vocabulary deliberately never says "healed".
case .heal: authorship = .heal
case .user: authorship = .user
}
// The committer stays the user throughout 06's recorded-by convention, which is why
// only the author varies here.
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
switch authorship {
case let .foreign(identity): author = identity
case .heal: author = CommitAttribution.integrityIdentity
case .user: author = user
}
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign
+12 -2
View File
@@ -90,15 +90,25 @@ public final class GitBranchSwitcher {
/// A clean failure "surfaces as a one-shot banner failure naming the operation and the error,
/// the tree left as it was" (06 Interaction with external writers).
///
/// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote
/// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
///
/// **Which row that is, settled 2026-07-31** (02-architecture.md The banner surface): the
/// one-shot failure class's message-carrying git shape error tone, failure rank, dismissable
/// and untimed. What travels is the operation and the underlying message; the sentence
/// ("Couldn't switch branches ") is `BannerCenter`'s, which is why nothing here composes one.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
///
/// **A warning-tone loss row, not a failure** (02 The banner surface, settled 2026-07-31):
/// "recovery notices report a success, not a failure, and stay warning-tone" the abort put the
/// previous state back, and the row exists so the user learns that it happened.
@ObservationIgnored
public var reportRecovery: (@MainActor (String) -> Void)?
+127 -8
View File
@@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding {
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// A genuine restore failure surfaced as 02's one-shot banner by whoever wires it.
///
/// **The direction travels with the failure** (02-architecture.md The banner surface, settled
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
/// words "Undo failed", "Redo failed" and this object is the only one that knows which key
/// was pressed. Everything past that boundary is the banner's: the closure receives the
/// direction and libgit2's own message, never a sentence composed here.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
// MARK: - The cached stack
@@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding {
guard let index = crossableIndex() else { return }
let crossed = ancestry[index]
guard let target = crossed.parentOID else { return }
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
let landed = await restore(
.undo,
to: target,
message: Self.restoreSubject(.undo, crossing: crossed.subject)
)
guard landed else { return }
redoCommits.append(crossed)
pointerOID = target
@@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding {
case .redo:
guard let target = redoCommits.last else { return }
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
let landed = await restore(
.redo,
to: target.oid,
message: Self.restoreSubject(.redo, crossing: target.subject)
)
guard landed else { return }
redoCommits.removeLast()
// The commit just restored *to* is the one the next Z crosses again the classic dance,
@@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding {
/// `message` is both the commit's subject and the bracket's completion announcement
/// (10-accessibility.md Live board announcements: "bracketed operations announce once, at
/// completion") one sentence, so the trail and the speech cannot disagree about what happened.
private func restore(to target: String, message: String) async -> Bool {
///
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
/// and it is named by the key the user pressed rather than by the subject the restore would have
/// carried (`reportFailure`).
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
let root = boardRoot
let excluded = healPaths
@@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding {
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
}).value else {
report("this board's repository could not be read")
report(direction, "this board's repository could not be read")
return false
}
@@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding {
case let .held(pause):
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
case let .failed(failure):
self.reportFailure?(failure)
self.reportFailure?(direction, failure)
}
}
@@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding {
return nil
}
private func report(_ message: String) {
reportFailure?(GitOperationFailure(
private func report(_ direction: HistoryDirection, _ message: String) {
reportFailure?(direction, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: message
))
}
// MARK: - The restore subject
/// **What a restore commit is called** a pure function of the crossed subject and the
/// direction, so the rule can be read (and pinned) without a repository.
///
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: subject"
/// or "Redo: subject". **Subjects don't nest** (06 Commit messages, settled 2026-07-31):
/// when the crossed subject already carries a restore prefix the post-relaunch case, where the
/// reseed has made old restore commits ordinary steps the composer "emits the *inverse* label
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
///
/// ### Why the two directions read the crossed subject differently
///
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
/// commit's **parent** the state before it so it emits that commit's inverse; a redo
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
/// its mirror true with it: Z back across an "Undo: S" step lands on the tree where S is out,
/// and says "Undo: S" the truer label, rather than the "Redo: S" the Z that crossed it
/// already used for the opposite tree.
///
/// ### The legacy double prefix
///
/// "Undo: Undo: S" exists in the wild the shipped nesting build made them and the honest
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
/// **"Undo: S"** the tree it restores is the one without S, and saying "Redo: S" there would be
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
///
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
/// open with a prefix gets the inverse label too; that's cosmetic the restore itself is
/// unaffected".
public nonisolated static func restoreSubject(
_ direction: HistoryDirection,
crossing subject: String
) -> String {
let reading = RestoreSubjectReading(of: subject)
let emitted = switch direction {
case .undo: reading.polarity.inverse
case .redo: reading.polarity
}
return "\(emitted.label): \(reading.base)"
}
}
// MARK: - Helpers
/// What a subject says about its own base subject: is that change *in* the tree the subject
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
private enum RestorePolarity {
/// The base subject's change is in the tree every ordinary commit, and every "Redo: S".
case applied
/// The base subject's change has been taken back out "Undo: S".
case reverted
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
/// The word that states this reading in a subject.
var label: String { self == .applied ? "Redo" : "Undo" }
/// The same word as a prefix the only two this composer emits, and the only two it reads, so
/// that reading and writing can never drift apart.
var prefix: String { "\(label): " }
}
/// One subject read as "a base subject, plus what its restore prefixes say about it".
///
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
/// subject, not a label with nothing after it.
private struct RestoreSubjectReading {
let base: String
let polarity: RestorePolarity
init(of subject: String) {
var base = subject
var polarity = RestorePolarity.applied
while true {
let read: RestorePolarity
if base.hasPrefix(RestorePolarity.reverted.prefix) {
read = .reverted
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
read = .applied
} else {
break
}
let rest = String(base.dropFirst(read.prefix.count))
guard !rest.isEmpty else { break }
base = rest
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
if read == .reverted { polarity = polarity.inverse }
}
self.base = base
self.polarity = polarity
}
}
+26 -15
View File
@@ -9,8 +9,10 @@ import Foundation
///
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the
/// file, portable to any git client, per-board by nature".
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
/// sheet is built, which changes nothing about this file.
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
/// `shortname@hostname` git's own no-config fallback shape, zero ceremony."
///
@@ -142,8 +144,19 @@ enum GitConfigFile {
}
/// The parse, over text the pure half, and where the format's edges are decided.
///
/// **Reads take the last plain-section value** (06-history-undo.md Interaction with external
/// writers, blessed 2026-07-31): "the reader like git itself takes the last plain-section
/// value, which is exactly what an append produces."
///
/// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
/// key in git's own model `user.work.name`, not `user.name` so its values are not answers to
/// this question at all, and reading one would sign the user's commits with an identity they
/// filed under a name this app never asked about. Last-wins still holds inside the plain
/// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
/// without the writer ever touching what came before it.
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
var section: String?
var isPlainUserSection = false
var name: String?
var email: String?
@@ -152,17 +165,16 @@ enum GitConfigFile {
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
if line.hasPrefix("[") {
// `[user]`, and `[user "work"]` a subsection is somebody else's scope, so the
// header's first token is what names the section.
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
section = header
let section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
isPlainUserSection = section == "user" && !header.contains("\"")
continue
}
guard section == "user", let separator = line.firstIndex(of: "=") else { continue }
guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue }
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
switch key {
@@ -177,10 +189,10 @@ enum GitConfigFile {
// MARK: Writing
/// **The popover's identity fields, landing in the file** (06-history-undo.md Interaction with
/// external writers: "The board popover's git section exposes name/email fields that **write that
/// repo-local config** the setting *is* the file, portable to any git client, per-board by
/// nature").
/// **The identity fields, landing in the file** (06-history-undo.md Interaction with external
/// writers: "The board settings sheet's identity section exposes name/email fields that **write
/// that repo-local config** the setting *is* the file, portable to any git client, per-board by
/// nature"; the fields are popover-hosted until that sheet is built).
///
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
@@ -225,10 +237,9 @@ enum GitConfigFile {
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
/// aimed somewhere else much the worse error, whatever the read side does with it.
///
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
/// meets whichever section it is in its own recorded call. The two agree in practice for
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
/// its keys are the last ones the reader meets.)
/// (The read side, `identity(inConfigText:)`, scopes itself to plain sections for the same
/// reason and takes the last one's value, so the two halves agree by construction rather than
/// by coincidence.)
var isPlainUserSection = false
/// Where a key the file does not yet have would be inserted: just after the last line of the
/// plain `[user]` section, or `nil` while there is no such section.
+50 -44
View File
@@ -50,9 +50,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve
///
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
/// its own schedule. The one seed it does write a `.gitignore`, at init and never again
/// (06 Repository hygiene) is the app's last word on that file rather than the start of a
/// relationship with it.
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
/// healed in at open), so all that survives here is a last-chance check that the file exists before
/// the initial commit freezes the tree see `seedGitignoreIfAbsent(at:)`.
enum GitRepository {
/// **The root commit's own subject** (06-history-undo.md Rules Abnormal repo states,
@@ -72,20 +73,11 @@ enum GitRepository {
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
/// the repo.
///
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
/// **The initial branch is `main`** (06 Rules Opt-in init, blessed 2026-07-31): "the host's
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
/// deterministically git's modern default, the pathfinder's choice."
static let initialBranchName = "main"
/// **The whole of the seeded `.gitignore`** (06-history-undo.md Repository hygiene: "Adding git
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
///
/// One line, because one line is what the rule says and because every additional entry would be
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
/// the entry that earns its place: the Finder writes one into every folder a user looks at, and
/// on a board that means one per lane and one per card, each churning as icons and window
/// positions move noise that would otherwise be committed by the whole-tree stage, forever,
/// under the user's own name.
static let seededGitignore = ".DS_Store\n"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
// MARK: Opt-in init
@@ -97,24 +89,39 @@ enum GitRepository {
/// is protected from the moment git exists" so the two halves are one operation and a failure
/// in either is one failure.
///
/// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
/// Between them sits a last-chance `.gitignore` check the file is the board's rather than
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
///
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
/// makes it impossible rather than merely unlikely.
/// **Create re-runs full detection and refuses anything but mode none** (06 Rules Detection,
/// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it
/// reads clean none, so the forbidden nested init is impossible even on a raced or stale read."
///
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
/// detected at board open, which can be minutes old a `git init` in a terminal at the board root
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
/// probable. (Detection has no *unverifiable* answer yet 06's denial-is-not-absence distinction
/// is not built so "clean none" is spelled `.none` for now.)
///
/// Returns the branch the root commit landed on, which is the popover's display line.
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
let operation = "Adding git to this board"
guard !BoardGitMode.hasGitEntry(at: boardRoot) else {
switch BoardGitMode.detect(boardRoot: boardRoot) {
case .none:
break
case .git:
return .failure(GitOperationFailure(
operation: operation,
message: "this board already has a git repository"
))
case .repoNested:
return .failure(GitOperationFailure(
operation: operation,
message: "this board lives inside a repository; Lanework leaves it to that repository"
))
}
let gitDirectory: URL
@@ -189,33 +196,32 @@ enum GitRepository {
}
}
/// **The `.gitignore` seed, written at init and never again** (06-history-undo.md Repository
/// hygiene: "the app never edits an existing one and never manages the file afterward it's the
/// user's from then on").
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
///
/// Three properties, and they are the feature:
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md Repository hygiene,
/// re-ruled: "`.gitignore` seeded on every board, never touched after git or not"). Every board
/// the app creates is born with one, and every board it opens is healed into having one
/// (`BoardStore.seedGitignore`) and add-git can only run on a board that is *open* and writable,
/// so by the time this line is reached the file is essentially always already there and this call
/// writes nothing.
///
/// - **Only when absent.** A board that already carries a `.gitignore` from a template, from a
/// clone, from the user is left byte for byte alone. `fileExists` rather than a read, so a
/// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks
/// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely
/// because nothing in the app reads this file).
/// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing
/// re-checks it, no heal restores it, no later version of the app appends to it: a user who
/// deletes the seeded line has deleted it.
/// - **Only on the app's own init.** Adoption seeds nothing an adopted repository is somebody
/// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested
/// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode
/// but `none`, so this function is unreachable from there.
/// **It stays anyway, and stays here before the stage below.** The one case it still answers is
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
/// failure that armed its memo, a picture that has not changed since), the initial commit would
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
/// this app has no operation that could ever remove it (06 Deleting never forgets). One
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
/// permanent record.
///
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s one seed text, one write-only-when-free
/// rule, `lstat` semantics so this cannot drift from what board creation and the heal write.
///
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
/// simply will not carry a `.gitignore`, and a board with none is an ordinary board surfacing a
/// banner about a courtesy file would be louder than the thing it reports.
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
/// open surfacing a banner about a courtesy file would be louder than the thing it reports.
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
let url = boardRoot.appendingPathComponent(".gitignore")
guard !FileManager.default.fileExists(atPath: url.path) else { return }
do {
try Data(seededGitignore.utf8).write(to: url, options: .atomic)
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
} catch {
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
}
+42 -6
View File
@@ -58,14 +58,26 @@ public final class HistoryStore {
/// click from running `git_repository_init` twice.
public private(set) var isAddingGit = false
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
/// The last add-git failure while the form that asked is still on screen, or `nil`.
///
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
/// add-git's failure surface either way.
/// **Form-anchored operations answer at the form first** (06 Interaction with external writers,
/// ruled 2026-07-31): "add-git and later sheet-asked operations like verify-remote fail into
/// an inline caption in the sheet's relevant section while the sheet is up if the sheet has been
/// dismissed before the answer arrives, the failure falls back to the one-shot banner above
/// inline is the primary surface, never a silence trap."
///
/// So this property is exactly the *inline* half: it is set only while `isFormVisible`, and
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
///
/// The form is the popover's git section today and the board settings sheet once that exists
/// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and
/// `noteFormVisible(_:)` is the one line the sheet will re-point.
public private(set) var lastFailure: GitOperationFailure?
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
public private(set) var isFormVisible = false
/// **The auto-commit engine** (06-history-undo.md Rules Auto-commit), or `nil` on a board
/// there is no repository to commit into.
///
@@ -152,6 +164,22 @@ public final class HistoryStore {
@ObservationIgnored
public var didAddGit: (@MainActor () -> Void)?
/// **The banner half of the form-anchored posture** where a form-asked failure goes when the
/// form is gone (`BannerCenter.postGitFailure`). `nil` on a storeless `HistoryStore`, which has no
/// strip to post to; the inline half still works there.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
///
/// A `Bool` rather than a count because there is one such form per board at a time: the popover is
/// built fresh on each open and the settings sheet is modal to its board window.
public func noteFormVisible(_ visible: Bool) {
isFormVisible = visible
if !visible { lastFailure = nil }
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
@@ -283,7 +311,15 @@ public final class HistoryStore {
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
return true
case .failure(let failure):
lastFailure = failure
// **Inline while the form is up, the banner when it is not** (06, ruled 2026-07-31) the
// answer can outlive the surface that asked for it, and a failure with nowhere to land
// would be the silence trap the ruling names.
if isFormVisible {
lastFailure = failure
} else {
lastFailure = nil
reportFailure?(failure)
}
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
return false
}