import Foundation // MARK: - CommitMessageEngine /// **What a commit says** (06-history-undo.md ▸ Commit messages) — a pure, total function from two /// board snapshots plus a changed-path list to one commit message. /// /// ### The shape of the rule /// /// "Messages compose at commit time from a structural diff of two board snapshots (last-committed /// vs. current) — **never by intercepting operations**. Items match by id across the *whole* board, /// so a lane change is distinguishable from delete+add and a cross-lane move reads as a move." /// Everything below follows from that one sentence: there is no write-site vocabulary anywhere in /// this file, no `WriteOperation`, no receipt. A foreign `mv` and the app's own drag produce the same /// two snapshots and therefore the same message — "origin lives in the author field, not in message /// prose" — which is why `CommitAuthorship` reaches only two rules here: the root commit's fixed /// subject, and the heal window's Repair reading of a folder remint. /// /// ### Purity, and where the impurity went /// /// Nothing in here reads a file, opens a repository, or asks what time it is. Both snapshots and the /// guide's text arrive as values on `CommitMessageRequest`; `GitAutoCommitter` resolves them once per /// flush, the last-committed one through `GitHeadSnapshot`. That is what makes the whole vocabulary — /// every subject form, every fold, every trash reading — testable with two snapshots and no /// repository at all. /// /// ### The changed-path list is a filter, not a hint /// /// A debounce window splits into as many as three commits (foreign, heal, user — 06 ▸ Interaction /// with external writers), and each stages *its own paths*. All three compose against the same HEAD, /// so the only thing keeping each message about its own commit is `request.changedPaths`: an event is /// composed **only when one of the paths it was read from is in this commit's list**. The same filter /// earns three more rules for free — a card whose folder is staged around for an open Edit session /// composes nothing, a `.gitignore`d attachment never composes a phantom Attach, and a stray-only /// window still commits with its strays named. enum CommitMessageEngine { /// **06's own mixed-window fallback**: "genuinely mixed windows fall back to 'Update board' — /// always with a bulleted body naming every event". static let mixedSubject = "Update board" /// **An untitled item reads "(untitled)" — never a bare `""`** (06 ▸ Commit messages: "a /// pathfinder edge fixed, not carried"). /// /// Deliberately *not* `AccessibilityPhrases.untitled` ("Untitled"), which is the word the board /// face and VoiceOver draw. This is a git log, where the parenthesized lowercase form is the /// long-standing convention for "no value here" and cannot be mistaken for a card actually /// titled "Untitled". static let untitledPlaceholder = "(untitled)" /// **~40 characters, in subjects only** (06) — "keeping `git log --oneline` sane"; body lines /// carry full titles. static let titleLimit = 40 /// What a card's home is called when that home is the trash — the one "lane name" that is not a /// lane's title, so "Move card 'X' to …" and the plural fold have something true to say about a /// container with no identity of its own (`BoardModel.trash`). static let trashDestination = "the trash" // MARK: - Entry point /// One request in, one whole message out — a subject, and a body when there is more to say. static func message(for request: CommitMessageRequest) -> String { // **The one commit with a subject of its own** (06 ▸ Rules ▸ Abnormal repo states): "it // commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is // no last-committed snapshot to diff against." guard !request.isRootCommit else { return GitRepository.initialCommitSubject } return assemble(events(for: request)) } /// Every event this commit is composed of — model events in board reading order, then the /// path-shaped ones. /// /// Split out from `message(for:)` so a test can assert *what was seen* apart from *how it was /// worded*: the two halves fail differently, and a diff bug should not have to be read out of a /// phrasing assertion. static func events(for request: CommitMessageRequest) -> [Event] { let changed = Set(request.changedPaths.map(\.path)) var model: [Event] = [] if let previous = request.previousSnapshot, let current = request.snapshot { model = modelEvents(from: previous, to: current, request: request) .filter { event in event.paths.contains(where: changed.contains) } } return model + pathEvents(for: request, claimedBy: model) } // MARK: - Assembly /// Subject, then a blank line and a body when one helps. /// /// - One event: its own subject, plus its detail line where it has one. /// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event — /// "so the oneline log stays scannable and the full message stays complete". private static func assemble(_ events: [Event]) -> String { guard !events.isEmpty else { return mixedSubject } if events.count == 1 { guard let detail = events[0].detail else { return events[0].subject } return "\(events[0].subject)\n\n\(detail)" } let body = events.map { "- \($0.bullet)" }.joined(separator: "\n") return "\(subject(for: headline(of: events)))\n\n\(body)" } /// The events allowed to *choose* the subject, in the order 06 ranks them. /// /// - **Implied events don't steal the subject**: "deleting a lane with five cards reads 'Delete /// lane 'X'' with the card deletions as body bullets — not 'Update board'." /// - **Model events keep the subject when present**; non-snapshot changes "then ride as body /// bullets — recorded, never silently absorbed under an unrelated subject." private static func headline(of events: [Event]) -> [Event] { let candidates = events.filter { !$0.implied } let model = candidates.filter(\.kind.isModel) if !model.isEmpty { return model } if !candidates.isEmpty { return candidates } return events } /// "A single event is the subject; several events of one kind fold into a plural subject, with /// shared destinations preserved; genuinely mixed windows fall back to 'Update board'." private static func subject(for headline: [Event]) -> String { guard let first = headline.first else { return mixedSubject } if headline.count == 1 { return first.subject } guard Set(headline.map(\.kind)).count == 1 else { return mixedSubject } let destinations = Set(headline.compactMap(\.destination)) return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil) } // MARK: - The structural diff private static func modelEvents( from previous: BoardModel, to current: BoardModel, request: CommitMessageRequest ) -> [Event] { let previousLanes = laneIndex(of: previous) let currentLanes = laneIndex(of: current) var events = boardEvents(from: previous, to: current) let lanes = laneEvents(previous: previousLanes, current: currentLanes) events += lanes events += laneSequenceEvent(from: previous, to: current) events += cardEvents( previous: cardIndex(of: previous), current: cardIndex(of: current), previousLanes: previousLanes, currentLanes: currentLanes, laneOutcomes: lanes, authorship: request.authorship, renames: renamedPaths(in: request) ) events += cardSequenceEvents(from: previous, to: current, currentLanes: currentLanes) return events } // MARK: Board level private static func boardEvents(from previous: BoardModel, to current: BoardModel) -> [Event] { let paths = [Paths.boardIndex] var events: [Event] = [] if title(previous.title) != title(current.title) { events.append(renameEvent( kind: .renameBoard, noun: "board", old: title(previous.title), new: title(current.title), paths: paths )) } if previous.body != current.body { events.append(Event(kind: .editBoard, subject: "Edit board description", paths: paths)) } if styleDiffers(previous.background, previous.icon, previous.iconColor, current.background, current.icon, current.iconColor) { events.append(Event(kind: .restyleBoard, subject: "Restyle board", paths: paths)) } events += metadataEvents( previous: previous.document, current: current.document, noun: "board", itemTitle: nil, kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard), paths: paths ) return events } // MARK: Lane level /// A lane the diff can see: on the strip, or as one of the trash's opaque rows. /// /// `BoardDiff.LaneEntry`'s twin rather than a shared type, deliberately: the announcer asks three /// questions of a lane and this asks a dozen, at a granularity (which *field* changed) the digest /// is explicitly built not to keep — see `BoardDiff`'s own note, which says pro-m1's composer /// "will phrase the very same counts differently without either of them knowing about the other". private struct LaneEntry { let container: ItemContainer let title: FieldValue let document: FrontmatterDocument let path: String /// The live lane, or `nil` for a trash row — which has no body, style or width to compare /// (03-board-ui.md § Trash: the row draws a title and a count and takes no styling accents). let live: Lane? var displayTitle: String { CommitMessageEngine.title(title) } } private static func laneIndex(of board: BoardModel) -> [ItemID: LaneEntry] { var index: [ItemID: LaneEntry] = [:] for lane in board.lanes { index[lane.id] = LaneEntry( container: .board, title: lane.title, document: lane.document, path: Paths.index(lane.id.rawValue), live: lane ) } for lane in board.trashedLanes { index[lane.id] = LaneEntry( container: .trash, title: lane.title, document: lane.document, path: Paths.index(Paths.trashFolder, lane.id.rawValue), live: nil ) } return index } private static func laneEvents( previous: [ItemID: LaneEntry], current: [ItemID: LaneEntry] ) -> [Event] { var events: [Event] = [] // Arrivals and departures, ordered by folder spelling — an identity on one side only has no // position on the other to sort by, and a message must not depend on dictionary order. for id in current.keys.sorted(by: idOrder) where previous[id] == nil { guard let lane = current[id] else { continue } events.append(Event( kind: .addLane, subject: "Add lane \(quotedSubject(lane.displayTitle))", bullet: "Add lane \(quoted(lane.displayTitle))", paths: [lane.path] )) } for id in previous.keys.sorted(by: idOrder) where current[id] == nil { guard let lane = previous[id] else { continue } // **The trash pair by diff shape alone**: an item leaving the tree entirely is a // permanent deletion, whatever container it left from. events.append(Event( kind: .purgeLane, subject: "Permanently delete lane \(quotedSubject(lane.displayTitle))", bullet: "Permanently delete lane \(quoted(lane.displayTitle))", paths: [lane.path] )) } for id in current.keys.sorted(by: idOrder) { guard let new = current[id], let old = previous[id] else { continue } // The crossing, decided by shape and nothing else: into `.trash/` is Delete, out of it is // Restore (06 ▸ Commit messages, the trash pair). if old.container != new.container { let deleting = new.container == .trash events.append(Event( kind: deleting ? .deleteLane : .restoreLane, subject: "\(deleting ? "Delete" : "Restore") lane \(quotedSubject(new.displayTitle))", bullet: "\(deleting ? "Delete" : "Restore") lane \(quoted(new.displayTitle))", paths: [old.path, new.path] )) continue } let paths = [new.path] if old.displayTitle != new.displayTitle { events.append(renameEvent( kind: .renameLane, noun: "lane", old: old.displayTitle, new: new.displayTitle, paths: paths )) } if let oldLane = old.live, let newLane = new.live { if oldLane.body != newLane.body { events.append(Event( kind: .editLane, subject: "Edit lane \(quotedSubject(new.displayTitle))", bullet: "Edit lane \(quoted(new.displayTitle))", paths: paths )) } if styleDiffers(oldLane.background, oldLane.icon, oldLane.iconColor, newLane.background, newLane.icon, newLane.iconColor) { events.append(Event( kind: .restyleLane, subject: "Restyle lane \(quotedSubject(new.displayTitle))", bullet: "Restyle lane \(quoted(new.displayTitle))", paths: paths )) } let width = newLane.width.value ?? 1 if (oldLane.width.value ?? 1) != width { events.append(Event( kind: .resizeLane, subject: "Resize lane \(quotedSubject(new.displayTitle)) to \(width)×", bullet: "Resize lane \(quoted(new.displayTitle)) to \(width)×", paths: paths )) } } events += metadataEvents( previous: old.document, current: new.document, noun: "lane", itemTitle: new.displayTitle, kinds: (.relabelLane, .assignLane, .dueLane, .updateLane), paths: paths ) } return events } /// **Sequence, not raw `order`** (06): "an order change that *repositions* an item among its /// siblings composes Reorder", while a renumber's rescale composes nothing. Lanes present on only /// one side drop out of both sequences first, so an add, a delete or a rescale can never trip /// this — only an actual repositioning does. It is also why "a midpoint-exhaustion renumber /// batches with the insert or move that triggered it": the insert changes the sequence by exactly /// the inserted item, which the filter removes. private static func laneSequenceEvent(from previous: BoardModel, to current: BoardModel) -> [Event] { let previousIDs = Set(previous.lanes.map(\.id)) let currentIDs = Set(current.lanes.map(\.id)) guard previous.lanes.map(\.id).filter(currentIDs.contains) != current.lanes.map(\.id).filter(previousIDs.contains) else { return [] } return [Event( kind: .reorderLanes, subject: "Reorder lanes", paths: current.lanes.map { Paths.index($0.id.rawValue) } )] } // MARK: Card level /// A card plus where it sits — the whole-board lookup that tells a move (an id landing in a /// different lane) from an add or a delete (an id present on one side only). private struct CardEntry { let card: Card let container: ItemContainer /// The lane this card is in, or `nil` when it sits loose in the trash. let lane: ItemID? let path: String let attachmentFolder: String var displayTitle: String { CommitMessageEngine.title(card.title) } } private static func cardIndex(of board: BoardModel) -> [ItemID: CardEntry] { var index: [ItemID: CardEntry] = [:] for lane in board.lanes { for card in lane.cards { index[card.id] = CardEntry( card: card, container: .board, lane: lane.id, path: Paths.index(lane.id.rawValue, card.id.rawValue), attachmentFolder: Paths.attachments(lane.id.rawValue, card.id.rawValue) ) } } for card in board.trash { index[card.id] = CardEntry( card: card, container: .trash, lane: nil, path: Paths.index(Paths.trashFolder, card.id.rawValue), attachmentFolder: Paths.attachments(Paths.trashFolder, card.id.rawValue) ) } return index } private static func cardEvents( previous: [ItemID: CardEntry], current: [ItemID: CardEntry], previousLanes: [ItemID: LaneEntry], currentLanes: [ItemID: LaneEntry], laneOutcomes: [Event], authorship: CommitAuthorship, renames: (arrivals: Set, departures: Set) ) -> [Event] { var events: [Event] = [] let arrivedLanes = Set(laneOutcomes.filter { $0.kind == .addLane }.flatMap(\.paths)) let restoredLanes = Set(laneOutcomes.filter { $0.kind == .restoreLane }.flatMap(\.paths)) for id in current.keys.sorted(by: idOrder) where previous[id] == nil { guard let entry = current[id] else { continue } // **The duplicate-id remint reads as Repair, not as an arrival** (06 ▸ Commit messages: // "app-mediated and heal-marked, so its separate heal commit names the remint directly // instead of reading the folder swap as Permanently delete + Add"). The shape it is // recognized by, inside a heal-classed window: a folder git paired as a *rename*, whose // departed twin the previous snapshot never held — because the loader withheld it as the // duplicate it was (`BoardLoader.dedupeIdentities`). Nothing else in a heal window has // that shape: a loose-file relocation renames a file rather than an identity folder, and // a legacy tombstone's migration moves an id the previous snapshot *did* hold. if case .heal = authorship, renames.arrivals.contains(entry.path), let departed = remintedTwin(of: entry, among: renames.departures, previous: previous) { events.append(Event( kind: .repairDuplicate, subject: "Repair duplicate of \(quotedSubject(entry.displayTitle))", bullet: "Repair duplicate of \(quoted(entry.displayTitle))", paths: [entry.path, departed] )) continue } // A card that arrived *with* its lane is the lane's event: body material, never a // subject — and when the lane came back out of the trash, so did the card, which is a // restore rather than an arrival however new the id looks to the diff. let lanePath = entry.lane.flatMap { currentLanes[$0]?.path } let restored = lanePath.map(restoredLanes.contains) ?? false let implied = restored || (lanePath.map(arrivedLanes.contains) ?? false) let destination = destinationName(of: entry, lanes: currentLanes) events.append(restored ? Event( kind: .restoreCard, subject: "Restore card \(quotedSubject(entry.displayTitle))", bullet: "Restore card \(quoted(entry.displayTitle))", implied: true, paths: [entry.path] ) : Event( kind: .addCard, subject: "Add card \(quotedSubject(entry.displayTitle))", bullet: "Add card \(quoted(entry.displayTitle)) to \(destination)", detail: "to \(destination)", destination: destination, implied: implied, paths: [entry.path] )) } for id in previous.keys.sorted(by: idOrder) where current[id] == nil { guard let entry = previous[id] else { continue } // A card whose *lane* went into the trash went with it — it is not in the snapshot at all // any more (a trashed lane is opaque), and calling that a permanent deletion would be a // lie about where the file is. Its fate is its lane's. switch fateOfLane(of: entry, previousLanes: previousLanes, currentLanes: currentLanes) { case .trashed: events.append(Event( kind: .deleteCard, subject: "Delete card \(quotedSubject(entry.displayTitle))", bullet: "Delete card \(quoted(entry.displayTitle))", implied: true, paths: [entry.path] )) case .gone: events.append(purgeEvent(entry, implied: true)) case .standing: events.append(purgeEvent(entry, implied: false)) } } for id in current.keys.sorted(by: idOrder) { guard let new = current[id], let old = previous[id] else { continue } events += cardChangeEvents(old: old, new: new, currentLanes: currentLanes) } return events } private static func purgeEvent(_ entry: CardEntry, implied: Bool) -> Event { Event( kind: .purgeCard, subject: "Permanently delete card \(quotedSubject(entry.displayTitle))", bullet: "Permanently delete card \(quoted(entry.displayTitle))", implied: implied, paths: [entry.path] ) } /// Every event for a card present in both snapshots — **independent checks, not a priority /// chain**: a card that changed lane *and* title in one window produces both events, exactly as /// two separate cards would. private static func cardChangeEvents( old: CardEntry, new: CardEntry, currentLanes: [ItemID: LaneEntry] ) -> [Event] { var events: [Event] = [] let paths = old.path == new.path ? [new.path] : [old.path, new.path] if old.container != new.container { // The crossing rule: into the trash is Delete, out of it is Restore — never a move, // however far the folder travelled. let deleting = new.container == .trash events.append(Event( kind: deleting ? .deleteCard : .restoreCard, subject: "\(deleting ? "Delete" : "Restore") card \(quotedSubject(new.displayTitle))", bullet: "\(deleting ? "Delete" : "Restore") card \(quoted(new.displayTitle))", paths: paths )) } else if old.lane != new.lane { let from = destinationName(of: old, lanes: currentLanes) let to = destinationName(of: new, lanes: currentLanes) events.append(Event( kind: .moveCard, subject: "Move card \(quotedSubject(new.displayTitle)) to \(truncated(to))", bullet: "Move card \(quoted(new.displayTitle)) from \(from) to \(to)", detail: "\(from) → \(to)", destination: to, paths: paths )) } if old.displayTitle != new.displayTitle { events.append(renameEvent( kind: .renameCard, noun: "card", old: old.displayTitle, new: new.displayTitle, paths: paths )) } if old.card.body != new.card.body { events.append(Event( kind: .editCard, subject: "Edit card \(quotedSubject(new.displayTitle))", bullet: "Edit card \(quoted(new.displayTitle))", paths: paths )) } if styleDiffers(old.card.background, old.card.icon, old.card.iconColor, new.card.background, new.card.icon, new.card.iconColor) { events.append(Event( kind: .restyleCard, subject: "Restyle card \(quotedSubject(new.displayTitle))", bullet: "Restyle card \(quoted(new.displayTitle))", paths: paths )) } // Attach / Remove — one event per file, named by the card it belongs to. Both ends' folders // are listed so a file that arrived with a moved card is still filtered in. let before = Set(old.card.attachments) let after = Set(new.card.attachments) for file in after.subtracting(before).sorted() { events.append(Event( kind: .attachFile, subject: "Attach \(quotedSubject(file)) to card \(quotedSubject(new.displayTitle))", bullet: "Attach \(quoted(file)) to card \(quoted(new.displayTitle))", destination: new.displayTitle, paths: [Paths.join(new.attachmentFolder, file), Paths.join(old.attachmentFolder, file)] )) } for file in before.subtracting(after).sorted() { events.append(Event( kind: .removeFile, subject: "Remove \(quotedSubject(file)) from card \(quotedSubject(new.displayTitle))", bullet: "Remove \(quoted(file)) from card \(quoted(new.displayTitle))", destination: new.displayTitle, paths: [Paths.join(old.attachmentFolder, file), Paths.join(new.attachmentFolder, file)] )) } events += metadataEvents( previous: old.card.document, current: new.card.document, noun: "card", itemTitle: new.displayTitle, kinds: (.relabelCard, .assignCard, .dueCard, .updateCard), paths: paths ) return events } /// Reorder within a lane — and within the trash — by the same common-subsequence rule the lanes /// use one level up, so a card added, deleted or moved in or out never trips it. private static func cardSequenceEvents( from previous: BoardModel, to current: BoardModel, currentLanes: [ItemID: LaneEntry] ) -> [Event] { func repositioned(_ before: [Card], _ after: [Card]) -> Bool { let beforeIDs = Set(before.map(\.id)) let afterIDs = Set(after.map(\.id)) return before.map(\.id).filter(afterIDs.contains) != after.map(\.id).filter(beforeIDs.contains) } var events: [Event] = [] let previousByID = Dictionary(uniqueKeysWithValues: previous.lanes.map { ($0.id, $0) }) for lane in current.lanes { guard let old = previousByID[lane.id], repositioned(old.cards, lane.cards) else { continue } let destination = currentLanes[lane.id]?.displayTitle ?? title(lane.title) events.append(Event( kind: .reorderCards, subject: "Reorder cards in \(truncated(destination))", bullet: "Reorder cards in \(destination)", destination: destination, paths: (old.cards + lane.cards).map { Paths.index(lane.id.rawValue, $0.id.rawValue) } )) } if repositioned(previous.trash, current.trash) { events.append(Event( kind: .reorderCards, subject: "Reorder cards in \(trashDestination)", destination: trashDestination, paths: (previous.trash + current.trash).map { Paths.index(Paths.trashFolder, $0.id.rawValue) } )) } return events } // MARK: The reserved metadata trio, and every other key /// **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**." /// /// 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`, /// `created`, `modified-by` and the on-touch heal's backfilled `kind` are all schema-owned, and a /// diff touching only those composes nothing (06 ▸ the bookkeeping rule). /// /// Values compare **as written** (`rawValue`) rather than as parsed YAML: the composer's business /// is that the file changed, and re-deriving an equivalence between `[a, b]` and `[a,b]` would be /// a second YAML semantics to keep honest. private static func metadataEvents( previous: FrontmatterDocument, current: FrontmatterDocument, noun: String, itemTitle: String?, kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind), paths: [String] ) -> [Event] { let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last }) let after = Dictionary(current.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last }) guard before != after else { return [] } func phrase(_ verb: String, _ render: (String) -> String) -> String { guard let itemTitle else { return "\(verb) \(noun)" } return "\(verb) \(noun) \(render(itemTitle))" } func event(_ kind: Kind, _ verb: String) -> Event { Event( kind: kind, subject: phrase(verb, quotedSubject), bullet: phrase(verb, quoted), paths: paths ) } func changed(_ key: String) -> Bool { before[key] != after[key] } var events: [Event] = [] 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. let trio: Set = [Keys.labels, Keys.assignees, Keys.due] if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) { events.append(event(kinds.generic, "Update")) } return events } // MARK: - Path events /// **Non-snapshot files commit too** (06): "beside the snapshot diff it receives the changed-path /// list, and non-snapshot paths compose *path-shaped events* — `CLAUDE.md` composes 'Update agent /// guide (vN)' … any other non-snapshot path composes 'Update '⟨path⟩'', folding plural". /// /// Four kinds of path never compose one: /// /// - a path a model event was already read from — it is already described; /// - an `index.md` the model *could* have spoken for and did not: that is the bookkeeping rule, and /// reporting the file would smuggle a bumped `modified` stamp back in as an event; /// - anything under `.trash/`, whose whole content is the model's business (a purge of a trashed /// 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. private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] { let claimed = Set(model.flatMap(\.paths)) var comments: [String: CommentGroup] = [:] var events: [Event] = [] for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) { let path = changed.path guard !claimed.contains(path), !(changed.isRename && changed.isDeletion) else { continue } // **The comment verb family, before every other rule** (01-storage-format.md § Enhanced // 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) continue } guard !Paths.isModelSilent(path) else { continue } // **N is a pure function of the file's content** (06; the m10 agent-guide card's deferred // bullet): the version marker on the guide's first line, read here rather than tagged at // the write site — "the no-interception rule stands". A `CLAUDE.md` with no marker is not // an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it // composes as the ordinary path it is. if path == AgentGuide.filename, let version = request.agentGuideText.flatMap(AgentGuide.installedVersion(of:)) { events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path])) continue } events.append(Event( kind: .updatePath, subject: "Update \(quotedSubject(path))", bullet: "Update \(quoted(path))", paths: [path] )) } return commentEvents(comments, model: model, request: request) + events } // 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 var paths: [String] = [] var hasArrival = false var hasSurvivor = false static func key(_ comment: CommentPath) -> String { switch comment.kind { case .draft: "\(comment.cardPath)|draft" case let .comment(id): "\(comment.cardPath)|comment|\(id.rawValue)" case let .trashed(id): "\(comment.cardPath)|trashed|\(id.rawValue)" } } mutating func add(_ changed: GitChangedPath) { paths.append(changed.path) if changed.isArrival { hasArrival = true } if !changed.isDeletion { hasSurvivor = true } } } /// **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//` composes 'Comment on ⟨card title⟩' / 'Edit /// comment on…' / 'Delete comment on…'"; 05-card-window.md ▸ The comments column for the post's /// own wording, and the same design session for the draft's "Draft comment on 'X'"). /// /// ### Why this is path shape and not a diff /// /// "Comments are window-scoped, outside the board snapshot" — the stated exception to snapshot /// completeness. So the composer's usual question ("what do the two boards say") has no answer /// here, and the family is read off *where a file sits* plus the one fact the survey already /// knows: whether HEAD had that path (`GitChangedPath.isArrival`). Arrival in a fresh comment /// folder is a post — the app's own post arrives as the `.draft` rename, and a foreign writer's /// arrives as a plain addition, and both read identically, which is the origin-agnostic rule one /// level down. /// /// ### What stays silent /// /// A comment whose *card* moved, was deleted, restored or purged: its files travelled because the /// card did, and the card's own event already says so. Naming a card's twelve comments beside /// "Delete card 'X'" would be the implied-events mistake with a longer body. private static func commentEvents( _ groups: [String: CommentGroup], model: [Event], request: CommitMessageRequest ) -> [Event] { guard !groups.isEmpty else { return [] } let titles = cardTitlesByPath(request) let relocated = relocatedCardFolders(in: model) // A comment that left `comments//` for `comments/.trash//` is one event, named at the // arriving end — the rename rule, restated for the case git did not pair the two halves. let trashedElsewhere = Set(groups.values.compactMap { group -> String? in guard case let .trashed(id) = group.comment.kind else { return nil } return "\(group.comment.cardPath)|comment|\(id.rawValue)" }) var events: [Event] = [] for key in groups.keys.sorted() { 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 func event(_ kind: Kind, _ verb: String) -> Event { Event( kind: kind, subject: "\(verb) on \(quotedSubject(card))", bullet: "\(verb) on \(quoted(card))", destination: card, paths: group.paths ) } switch group.comment.kind { case .draft: events.append(event(.commentDrafted, "Draft comment")) case .trashed: // Arriving in `comments/.trash/` is the delete; leaving it is the close purge. events.append(group.hasSurvivor ? event(.commentDeleted, "Delete comment") : event(.commentPurged, "Permanently delete comment")) case .comment: if !group.hasSurvivor { // The folder is gone. If this window also put it in `comments/.trash/`, that end // already spoke; otherwise it left the thread outright and this is the delete. guard !trashedElsewhere.contains(key) else { continue } events.append(event(.commentDeleted, "Delete comment")) continue } events.append(group.hasArrival ? event(.commentPosted, "Comment") : event(.commentEdited, "Edit comment")) } } return events } /// 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 { let relocating: Set = [ .addCard, .deleteCard, .restoreCard, .purgeCard, .moveCard, .repairDuplicate, ] return Set(model.filter { relocating.contains($0.kind) }.flatMap(\.paths).compactMap { path in let suffix = "/" + Paths.boardIndex guard path.hasSuffix(suffix) else { return nil } return String(path.dropLast(suffix.count)) }) } /// Card titles by card-folder path, from whichever snapshot still holds the card — the current one /// first, since a comment usually lands on a card that is still there. private static func cardTitlesByPath(_ request: CommitMessageRequest) -> [String: String] { var titles: [String: String] = [:] for board in [request.previousSnapshot, request.snapshot].compactMap({ $0 }) { for lane in board.lanes { for card in lane.cards { titles["\(lane.id.rawValue)/\(card.id.rawValue)"] = title(card.title) } } for card in board.trash { titles["\(Paths.trashFolder)/\(card.id.rawValue)"] = title(card.title) } } return titles } // MARK: - Shapes /// One describable change. Text is carried already-rendered: `subject` is the truncated form, /// `bullet` the full-title one, and `destination` the raw value the plural fold compares. struct Event: Equatable { let kind: Kind let subject: String let bullet: String /// The extra body line a sole event gets where the subject alone leaves something out. let detail: String? /// What several of this kind must agree on to keep it in a plural subject ("Move 3 cards to /// Done"). let destination: String? /// A foregone consequence of another event already reported — body material, never a subject. let implied: Bool /// The board-root-relative paths this event was read from. The commit's own path list is /// filtered against these, which is what keeps a split window's messages about their own /// commits. let paths: [String] init( kind: Kind, subject: String, bullet: String? = nil, detail: String? = nil, destination: String? = nil, implied: Bool = false, paths: [String] ) { self.kind = kind self.subject = subject self.bullet = bullet ?? subject self.detail = detail self.destination = destination self.implied = implied self.paths = paths } } /// 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. enum Kind: Hashable { case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard case relabelCard, assignCard, dueCard, updateCard case attachFile, removeFile, reorderCards, repairDuplicate case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane case relabelLane, assignLane, dueLane, updateLane, reorderLanes case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard case agentGuide, updatePath /// The comment verb family (01-storage-format.md § Enhanced schema) — path-shaped like the /// guide, because "comments are window-scoped, outside the board snapshot". case commentPosted, commentEdited, commentDeleted, commentDrafted, commentPurged /// Whether this event came from the snapshot diff — the rank that lets "model events keep the /// subject when present" be one comparison rather than a list. /// /// The comment family is **not** model, deliberately: a lane move that carries a comment /// change in the same window keeps the lane's subject, and a comment-only window still gets /// its own subject rather than "Update board", because the headline falls through to the /// non-model events before it ever reaches the fallback. var isModel: Bool { switch self { case .agentGuide, .updatePath, .commentPosted, .commentEdited, .commentDeleted, .commentDrafted, .commentPurged: false default: true } } /// The subject N events of this kind fold into, with a shared destination kept when every one /// of them agrees on it ("moved 3 cards, all to Done" is worth saying in a subject; "to 3 /// different places" isn't). func plural(_ count: Int, destination: String?) -> String { let target = destination.map { " to \(CommitMessageEngine.truncated($0))" } ?? "" switch self { case .addCard: return "Add \(count) cards\(target)" case .deleteCard: return "Delete \(count) cards" case .restoreCard: return "Restore \(count) cards" case .purgeCard: return "Permanently delete \(count) cards" case .moveCard: return "Move \(count) cards\(target)" case .renameCard: return "Rename \(count) cards" case .editCard: return "Edit \(count) cards" case .restyleCard: return "Restyle \(count) cards" 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 .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 .reorderCards: guard let destination else { return "Reorder cards in \(count) lanes" } return "Reorder cards in \(CommitMessageEngine.truncated(destination))" case .repairDuplicate: return "Repair \(count) duplicates" case .addLane: return "Add \(count) lanes" case .deleteLane: return "Delete \(count) lanes" case .restoreLane: return "Restore \(count) lanes" case .purgeLane: return "Permanently delete \(count) lanes" case .renameLane: return "Rename \(count) lanes" case .editLane: return "Edit \(count) lanes" case .restyleLane: return "Restyle \(count) lanes" case .resizeLane: return "Resize \(count) lanes" 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" // 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. case .reorderLanes: return "Reorder lanes" case .renameBoard: return "Rename board" case .editBoard: return "Edit board description" case .restyleBoard: return "Restyle board" case .relabelBoard: return "Relabel board" case .assignBoard: return "Assign board" case .dueBoard: return "Set due date on board" case .updateBoard: return CommitMessageEngine.mixedSubject case .agentGuide: return "Update agent guide" case .updatePath: return "Update \(count) files" // **The family's plurals fold on the card**, which is the only destination a comment has. // The post's is the one shape that will not go verb-first without inventing a verb the // design never wrote ("Post…", "Add…"), so it counts the noun the other three already // count: "3 comments on 'X'". case .commentPosted: guard let destination else { return "Comment on \(count) cards" } return "\(count) comments on \(CommitMessageEngine.quotedSubject(destination))" case .commentEdited: guard let destination else { return "Edit \(count) comments" } return "Edit \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" case .commentDeleted: guard let destination else { return "Delete \(count) comments" } return "Delete \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" case .commentPurged: guard let destination else { return "Permanently delete \(count) comments" } return "Permanently delete \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" // One draft per card, so several can only mean several cards. case .commentDrafted: return "Draft comment on \(count) cards" } } } /// The frontmatter keys 06 names one by one — reserved out of this version's UI /// (01-storage-format.md § Enhanced schema) and therefore ordinary unknown keys everywhere else /// in the app, which is exactly why the composer has to name them here. private enum Keys { static let labels = "labels" static let assignees = "assignees" static let due = "due" } // MARK: - Paths /// Board-root-relative paths in git's own spelling, and the one question the path filter asks. enum Paths { static let boardIndex = IntegrityRules.indexFileName static let trashFolder = IntegrityRules.trashFolderName static let attachmentsFolder = "attachments" static func join(_ folder: String, _ name: String) -> String { "\(folder)/\(name)" } static func index(_ components: String...) -> String { (components + [boardIndex]).joined(separator: "/") } static func attachments(_ parent: String, _ item: String) -> String { "\(parent)/\(item)/\(attachmentsFolder)" } /// Whether a path is one the *snapshot* speaks for, so that its silence means "nothing /// describable changed" rather than "nobody looked". /// /// 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⟩'". /// Whether a path could make the *snapshot* differ at all — every path the model speaks for, /// plus attachments, whose names it carries. /// /// The flush asks it before it materializes HEAD's tree: a window of nothing but strays and /// the agent guide composes path-shaped events, and reading two whole boards to describe a /// changed `.gitignore` would be work with no reader (`GitAutoCommitter.composition`). static func mightAffectSnapshot(_ path: String) -> Bool { isModelSilent(path) || path.split(separator: "/").contains(Substring(attachmentsFolder)) } static func isModelSilent(_ path: String) -> Bool { if path == boardIndex { return true } if path == trashFolder || path.hasPrefix(trashFolder + "/") { return true } let components = path.split(separator: "/").map(String.init) guard components.count == 2 || components.count == 3, components.last == boardIndex else { return false } return components.dropLast().allSatisfy(BoardLoader.isUUIDShaped) } } // MARK: - Rendering static func title(_ field: FieldValue) -> String { guard let value = field.value, !value.isEmpty else { return untitledPlaceholder } return value } static func truncated(_ text: String) -> String { text.count > titleLimit ? String(text.prefix(titleLimit)) + "…" : text } /// **Single quotes**, which is the form every example in 06 and on this card is written in ("Move /// card 'Fix login' to Doing", "Repair duplicate of 'Fix login'", "Update '⟨path⟩'") — the /// pathfinder's double quotes are not carried. static func quoted(_ text: String) -> String { "'\(text)'" } static func quotedSubject(_ text: String) -> String { quoted(truncated(text)) } /// **06's own rename form**, subject and bullet alike: "Rename lane 'Todo' → 'Doing'" — both ends /// in the subject, so no detail line is needed to say what it used to be called. private static func renameEvent( kind: Kind, noun: String, old: String, new: String, paths: [String] ) -> Event { Event( kind: kind, subject: "Rename \(noun) \(quotedSubject(old)) → \(quotedSubject(new))", bullet: "Rename \(noun) \(quoted(old)) → \(quoted(new))", paths: paths ) } // MARK: - Small rules /// The three cosmetic fields a board, a lane and a card all carry. Any difference among them is /// one Restyle, however many of the three moved. private static func styleDiffers( _ oldBackground: FieldValue, _ oldIcon: FieldValue, _ oldIconColor: FieldValue, _ newBackground: FieldValue, _ newIcon: FieldValue, _ newIconColor: FieldValue ) -> Bool { oldBackground != newBackground || oldIcon != newIcon || oldIconColor != newIconColor } /// Where a card is, said the way a subject says it: its lane's title, or the trash. private static func destinationName(of entry: CardEntry, lanes: [ItemID: LaneEntry]) -> String { guard let lane = entry.lane else { return trashDestination } return lanes[lane]?.displayTitle ?? untitledPlaceholder } /// What became of the lane a departed card was in — the fact that decides whether its departure /// is a permanent deletion or a delete that rode along with its lane. private enum LaneFate { case standing, trashed, gone } private static func fateOfLane( of entry: CardEntry, previousLanes: [ItemID: LaneEntry], currentLanes: [ItemID: LaneEntry] ) -> LaneFate { guard let lane = entry.lane else { return .standing } guard let now = currentLanes[lane] else { return previousLanes[lane] == nil ? .standing : .gone } return now.container == .trash ? .trashed : .standing } /// The departed half of a duplicate-id remint: a rename-paired departure sitting in the **same /// parent folder** as the arrival (the remint renames in place — `BoardWriter.renameFolder`) /// whose identity the previous snapshot never held, because the loader withheld it as the /// duplicate it was. private static func remintedTwin( of entry: CardEntry, among departures: Set, previous: [ItemID: CardEntry] ) -> String? { let parent = entry.path.parentOfItemFolder return departures.sorted().first { departure in guard departure != entry.path, departure.parentOfItemFolder == parent, let identity = departure.itemFolderName else { return false } return previous[ItemID(rawValue: identity)] == nil } } /// Both ends of every rename libgit2 paired up in this commit, split by direction. private static func renamedPaths( in request: CommitMessageRequest ) -> (arrivals: Set, departures: Set) { var arrivals: Set = [] var departures: Set = [] for changed in request.changedPaths where changed.isRename { if changed.isDeletion { departures.insert(changed.path) } else { arrivals.insert(changed.path) } } return (arrivals, departures) } /// A deterministic order for identities present on one side only — the folder spelling, which is /// the primary key on disk. private static func idOrder(_ lhs: ItemID, _ rhs: ItemID) -> Bool { lhs.rawValue < rhs.rawValue } } // MARK: - Path shorthands private extension String { /// `//index.md` → ``: the folder an item's own folder sits in. var parentOfItemFolder: String { let components = split(separator: "/").map(String.init) guard components.count > 2 else { return "" } return components.dropLast(2).joined(separator: "/") } /// `//index.md` → ``: the identity folder an `index.md` describes. var itemFolderName: String? { let components = split(separator: "/").map(String.init) guard components.count >= 2 else { return nil } return components[components.count - 2] } }