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 by16ef377: - 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:
@@ -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)".
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user