Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1256 lines
61 KiB
Swift
1256 lines
61 KiB
Swift
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 {
|
||
|
||
/// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring
|
||
/// the bare 'Update board' fallback"):
|
||
///
|
||
/// > **"Mixed update — N changes"**, always with a bulleted body naming every event, so the
|
||
/// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in
|
||
/// > the window shares one item — the card-window session flush's usual shape — the subject keeps
|
||
/// > the name: **"Mixed update — N changes to card '⟨title⟩'"**.
|
||
///
|
||
/// The named case is the reason this exists: a card window's close flush is *by construction* a
|
||
/// window of one card's changes, and "Update board" was the one subject that could not say which
|
||
/// card a whole session belonged to.
|
||
///
|
||
/// - Parameters:
|
||
/// - count: how many events the body will list — the subject counts changes, not commits.
|
||
/// - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they
|
||
/// genuinely span the board.
|
||
static func mixedSubject(_ count: Int, item: String? = nil) -> String {
|
||
let changes = count == 1 ? "1 change" : "\(count) changes"
|
||
guard let item else { return "Mixed update — \(changes)" }
|
||
return "Mixed update — \(changes) to \(item)"
|
||
}
|
||
|
||
/// The subject a window with no describable event at all falls to.
|
||
///
|
||
/// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the
|
||
/// snapshot diff, so a window whose every path composed nothing still commits rather than leaving
|
||
/// the tree dirty (06 ▸ Commit messages ▸ Non-snapshot files commit too). "0 changes" would be a
|
||
/// lie about a commit that does contain something; this says what it honestly is.
|
||
static let unnamedSubject = "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), request: 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], request: CommitMessageRequest) -> String {
|
||
guard !events.isEmpty else { return unnamedSubject }
|
||
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")
|
||
let subject = subject(for: headline(of: events))
|
||
?? mixedSubject(events.count, item: sharedItem(of: events, request: request))
|
||
return "\(subject)\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" — or `nil`, which is the genuinely mixed window the caller names
|
||
/// with `mixedSubject(_:item:)`.
|
||
private static func subject(for headline: [Event]) -> String? {
|
||
guard let first = headline.first else { return nil }
|
||
if headline.count == 1 { return first.subject }
|
||
guard Set(headline.map(\.kind)).count == 1 else { return nil }
|
||
let destinations = Set(headline.compactMap(\.destination))
|
||
return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil)
|
||
}
|
||
|
||
/// **The one item every event in this window belongs to, rendered** — "card 'Fix login'" — or
|
||
/// `nil` when they span more than one.
|
||
///
|
||
/// Read from the events' own **paths** rather than from a field each event would have to remember
|
||
/// to carry: an event's paths are the only thing in this engine that is always true about where it
|
||
/// came from, and a card's folder is the one component that survives a lane move. A window with a
|
||
/// path that belongs to no card at all — the board's own `index.md`, a lane, a stray, the agent
|
||
/// guide — is by definition not one card's window, so a single `nil` answer decides the whole
|
||
/// question.
|
||
private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? {
|
||
var folder: String?
|
||
for event in events {
|
||
for path in event.paths {
|
||
guard let card = cardFolder(of: path) else { return nil }
|
||
if let folder, folder != card { return nil }
|
||
folder = card
|
||
}
|
||
}
|
||
guard let folder else { return nil }
|
||
let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder
|
||
return "card \(quotedSubject(title))"
|
||
}
|
||
|
||
/// The `<lane>/<card>` (or `.trash/<card>`) folder a board-root-relative path belongs to, or `nil`
|
||
/// when it belongs to no card — the board's `index.md`, a lane's, a root stray.
|
||
///
|
||
/// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so
|
||
/// the one rule about where a card lives is not spelled twice.
|
||
private static func cardFolder(of path: String) -> String? {
|
||
if let comment = CommentPath.classify(path) { return comment.cardPath }
|
||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||
guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil }
|
||
guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil }
|
||
return "\(components[0])/\(components[1])"
|
||
}
|
||
|
||
// 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<String>
|
||
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<String>, departures: Set<String>)
|
||
) -> [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<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"))
|
||
}
|
||
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/<uuid>/` 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/<id>/` for `comments/.trash/<id>/` 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<String> {
|
||
let relocating: Set<Kind> = [
|
||
.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.unnamedSubject
|
||
|
||
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>) -> 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<String>, _ oldIcon: FieldValue<String>, _ oldIconColor: FieldValue<String>,
|
||
_ newBackground: FieldValue<String>, _ newIcon: FieldValue<String>, _ newIconColor: FieldValue<String>
|
||
) -> 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<String>,
|
||
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<String>, departures: Set<String>) {
|
||
var arrivals: Set<String> = []
|
||
var departures: Set<String> = []
|
||
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 {
|
||
|
||
/// `<lane>/<card>/index.md` → `<lane>`: 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: "/")
|
||
}
|
||
|
||
/// `<lane>/<card>/index.md` → `<card>`: 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]
|
||
}
|
||
}
|