From 563999655fa395e10ba6759ed652f239c5dbf265 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 31 Jul 2026 14:54:17 -0400 Subject: [PATCH] Build the semantic commit-message engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommitMessageEngine replaces the interim composer as the wired default: a pure total function from two snapshots + changed paths to a message. Full vocabulary — Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove; Repair for the duplicate remint (detected as a heal-classed rename-paired arrival whose id the previous snapshot never held — the loader withholds duplicates, so the shape is a bare arrival); the trash triple by diff shape alone (into .trash = Delete, out = Restore, leaving the tree = Permanently delete); Relabel / Assign / Set due date plus the named generic for custom keys. Plural folding with shared destinations, implied events as body bullets never subjects, ~40-char subject truncation, "(untitled)". Bookkeeping (sequence-preserving renumbers, stamps, backfilled kind) composes nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md reads "Update agent guide (vN)" via the marker line (the m10 card's deferred bullet lands here), everything else "Update ''". The comment verb family per 01's ruling (comments shipped, so 06 gains the verbs): Comment on / Edit comment on / Delete comment on / Draft comment on / Permanently delete comment on '', grouped one event per comment folder, classified ahead of the model-silence rules, card title resolved from either snapshot. GIT_DELTA_ADDED is surfaced as GitChangedPath.isArrival — post vs edit is unanswerable from snapshots that exclude comments by ruling. A card moving with its thread swallows the comment events (implied-events one level down). The previous snapshot is HEAD's tree, materialized per flush into a temp dir (index.md blobs in full, other blobs zero-byte — the model reads attachment names, never bytes) and re-parsed through the one BoardLoader; never a value carried forward. changedPaths is a hard filter per split commit, which also earns the stage-around and kills phantom events. Launch catch-up and foreign windows compose through the same engine. 48 new tests (35 pure + comment family + engine-level); 2293 tests / 394 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/Git/CommitMessageComposer.swift | 75 +- Kanban/Git/CommitMessageEngine.swift | 1190 ++++++++++++++++++++++++ Kanban/Git/GitAutoCommitter.swift | 105 ++- Kanban/Git/GitCommitOperation.swift | 27 +- Kanban/Git/GitHeadSnapshot.swift | 158 ++++ KanbanTests/AutoCommitTests.swift | 148 ++- KanbanTests/CommitMessageTests.swift | 824 ++++++++++++++++ README.md | 2 +- 8 files changed, 2479 insertions(+), 50 deletions(-) create mode 100644 Kanban/Git/CommitMessageEngine.swift create mode 100644 Kanban/Git/GitHeadSnapshot.swift create mode 100644 KanbanTests/CommitMessageTests.swift diff --git a/Kanban/Git/CommitMessageComposer.swift b/Kanban/Git/CommitMessageComposer.swift index 30533b5..1761aa1 100644 --- a/Kanban/Git/CommitMessageComposer.swift +++ b/Kanban/Git/CommitMessageComposer.swift @@ -25,13 +25,18 @@ public enum CommitAuthorship: Sendable, Equatable { /// Everything the message engine is handed for one commit. /// /// **A struct rather than an argument list**, because the point of this seam is that the semantic -/// composer — the next card — plugs into it without reshaping the engine: it can start reading -/// `snapshot` and HEAD's tree the day it lands, and any input it turns out to need joins this type -/// rather than every call site. +/// composer plugs into it without reshaping the engine: any input it turns out to need joins this +/// type rather than every call site. It did need two — `previousSnapshot` and `agentGuideText`, both +/// below — and that is exactly what this shape was for. +/// +/// **Everything here is a value, and that is the design.** The composer (`CommitMessageEngine`) reads +/// no file and opens no repository: the flush resolves the environment once — the board as HEAD has +/// it, the board as the app has it, the guide's bytes — and the message is then a pure function of +/// this struct. Resolving once per *flush* rather than once per planned commit also means a +/// three-way-split window materializes HEAD's tree once, not three times. public struct CommitMessageRequest: Sendable { - /// The board this is a commit in — and, for the composer card, where HEAD's tree is read from - /// for the last-committed half of its diff. + /// The board this is a commit in. public let boardRoot: URL /// **The changed-path list** (06 ▸ Commit messages ▸ Non-snapshot files commit too: "beside the @@ -46,23 +51,46 @@ public struct CommitMessageRequest: Sendable { /// ("Initial board state", 06 ▸ Rules ▸ Abnormal repo states). public let isRootCommit: Bool - /// The board as the app last read it, or `nil` where no store is attached (a storeless - /// committer, a test). The current half of the composer's "last-committed vs. current" diff; the - /// other half is HEAD's tree, which the composer reads for itself. + /// **The current half** of the composer's "last-committed vs. current" diff — the board as the + /// app last read it, or, where no store is attached, as the flush read it off disk itself. + /// + /// `nil` only when neither could answer: a board whose working tree does not load at all, which + /// is a commit that will have to be described by its paths. public let snapshot: BoardModel? + /// **The last-committed half**: the board as HEAD's tree has it (`GitHeadSnapshot`). + /// + /// `nil` on an unborn HEAD — where `isRootCommit` already says everything — and on a HEAD whose + /// tree does not load as a board. It is read from the repository rather than carried forward from + /// the last commit the app made, because the app is not the only writer and because launch + /// catch-up has no carried value to offer: see `GitHeadSnapshot` for the whole of that argument. + public let previousSnapshot: BoardModel? + + /// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it — the one + /// non-snapshot file with a subject of its own ("Update agent guide (vN)", 06 ▸ Commit messages). + /// + /// The *text*, not the version: N is "a pure function of file content" + /// (`AgentGuide.installedVersion`), and keeping the parse on the composer's side is what keeps + /// that rule where the message vocabulary is. `nil` when the guide is not in this commit, cannot + /// be read, or has been deleted. + public let agentGuideText: String? + public init( boardRoot: URL, changedPaths: [GitChangedPath], authorship: CommitAuthorship, isRootCommit: Bool, - snapshot: BoardModel? + snapshot: BoardModel?, + previousSnapshot: BoardModel? = nil, + agentGuideText: String? = nil ) { self.boardRoot = boardRoot self.changedPaths = changedPaths self.authorship = authorship self.isRootCommit = isRootCommit self.snapshot = snapshot + self.previousSnapshot = previousSnapshot + self.agentGuideText = agentGuideText } } @@ -70,11 +98,11 @@ public struct CommitMessageRequest: Sendable { /// **What a commit says** (06-history-undo.md ▸ Commit messages). /// -/// The real implementation is the next card's: "a pure, testable function" composing from a -/// structural diff of two board snapshots, with the whole Add/Delete/Move/Rename/Edit vocabulary, -/// plural folding, path-shaped events for non-snapshot files, and the trash pair. None of that -/// exists yet; what exists is this protocol, so that arriving card is one type conforming here -/// rather than a change to the engine that calls it. +/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable +/// function" composing from a structural diff of two board snapshots, with the whole +/// Add/Delete/Move/Rename/Edit vocabulary, plural folding, path-shaped events for non-snapshot files, +/// and the trash pair. The protocol survives its interim purpose because it is still what lets a test +/// inject a fake and assert *when* a message was asked for without asserting what it said. /// /// `Sendable` because composition runs off the main actor, inside the same detached task that stages /// and commits — the message has to be in hand before `git_commit_create` is called, and none of the @@ -83,22 +111,19 @@ public protocol CommitMessageComposing: Sendable { func message(for request: CommitMessageRequest) -> String } -// MARK: - The interim +// MARK: - The wired composer -/// **The placeholder message**, deliberately the *fallback* 06 already names rather than an -/// invention: "genuinely mixed windows fall back to 'Update board'". +/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`). /// -/// So the trail an interim build writes is a trail the composer card only ever makes *more* -/// specific — no message written today becomes wrong tomorrow, and the root commit's subject is -/// already the settled one. -public struct InterimCommitMessage: CommitMessageComposing { - - /// 06's own mixed-window fallback. - public static let fallbackSubject = "Update board" +/// A one-line conformance over `CommitMessageEngine`, deliberately: the vocabulary is worth a file of +/// its own and nothing about it should have to know that a protocol exists. The type stays because +/// the seam takes an existential, and because a *named* default is what makes "the engine's composer +/// is the semantic one" assertable. +public struct SemanticCommitMessage: CommitMessageComposing { public init() {} public func message(for request: CommitMessageRequest) -> String { - request.isRootCommit ? GitRepository.initialCommitSubject : Self.fallbackSubject + CommitMessageEngine.message(for: request) } } diff --git a/Kanban/Git/CommitMessageEngine.swift b/Kanban/Git/CommitMessageEngine.swift new file mode 100644 index 0000000..92a6f57 --- /dev/null +++ b/Kanban/Git/CommitMessageEngine.swift @@ -0,0 +1,1190 @@ +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] + } +} diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index b5935ab..c014b60 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -85,9 +85,11 @@ public final class GitAutoCommitter { @ObservationIgnored public var holdRecheckInterval: Duration = .seconds(15) - /// **What a commit says** — the seam the semantic composer plugs into (next card). + /// **What a commit says** — the seam, holding the semantic composer by default + /// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was + /// asked for without asserting what it said. @ObservationIgnored - public var composer: any CommitMessageComposing = InterimCommitMessage() + public var composer: any CommitMessageComposing = SemanticCommitMessage() /// The board as the app last read it, for the composer's "current" half. `nil` where no store is /// attached, which is every storeless test. @@ -225,6 +227,12 @@ public final class GitAutoCommitter { /// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a /// version of somebody's file with no commit to recover it from). /// + /// **The semantic composer widened that bound**, and it is recorded rather than discovered: this + /// flush now also materializes HEAD's tree and reads it back through `BoardLoader` + /// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than + /// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the + /// commit that preserves somebody else's version, would be the worst message in the trail. + /// /// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns /// about foreign changes from landed reloads, so a write that lands inside the watcher's own /// debounce is not yet known to be pending. Bounded by that debounce, and the same window @@ -370,18 +378,91 @@ public final class GitAutoCommitter { return GitCommitOperation.perform( at: input.boardRoot, - commits: plan(changed, reading: reading, input: input) + commits: plan( + changed, + reading: reading, + input: input, + composition: composition(for: changed, input: input) + ) ) } + // MARK: - What the composer is handed + + /// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural + /// diff of two board snapshots — last-committed vs. current"). + /// + /// Once per *flush*, not once per planned commit: a window that splits three ways + /// (foreign → heal → user) composes all three messages against the same HEAD, so materializing + /// HEAD's tree three times would be three answers to one question. Each message is then narrowed + /// to its own commit by `CommitMessageRequest.changedPaths`, which the split already narrows. + private struct Composition: Sendable { + var previous: BoardModel? + var current: BoardModel? + var agentGuideText: String? + } + + /// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept + /// here so `CommitMessageEngine` can be a pure function of values. + /// + /// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary + /// stray-only and guide-only window: those compose path-shaped events, and materializing a board + /// twice to describe a changed `.gitignore` would be work with no reader. + private nonisolated static func composition( + for changed: [GitChangedPath], + input: FlushInput + ) -> Composition { + var composition = Composition() + if changed.contains(where: { $0.path == AgentGuide.filename }) { + composition.agentGuideText = try? String( + contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename), + encoding: .utf8 + ) + } + // **The comment family needs a board but not a diff.** Comments are outside the snapshot + // entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about + // them — but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a + // card's title. So a comment-only window loads the current board and skips the materialization. + let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) } + let namesACard = changed.contains { CommentPath.classify($0.path) != nil } + guard touchesModel || namesACard else { return composition } + + // **The store's snapshot when there is one, disk when there is not.** A storeless committer is + // a real configuration (`HistoryStore.compose` without a session, every engine-level test), and + // a composer handed no current board could only ever shrug. Loading here rather than in + // `makeInput` keeps the read off the main actor, where every other read in this flush already + // is. + composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model) + guard touchesModel else { return composition } + composition.previous = GitHeadSnapshot.load(at: input.boardRoot) + return composition + } + /// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes. private nonisolated static func plan( _ changed: [GitChangedPath], reading: GitRepositoryReading, - input: FlushInput + input: FlushInput, + composition: Composition ) -> [PlannedCommit] { let user = GitCommitOperation.userIdentity(at: input.boardRoot) + func request( + _ paths: [GitChangedPath], + _ authorship: CommitAuthorship, + isRootCommit: Bool = false + ) -> CommitMessageRequest { + CommitMessageRequest( + boardRoot: input.boardRoot, + changedPaths: paths, + authorship: authorship, + isRootCommit: isRootCommit, + snapshot: composition.current, + previousSnapshot: composition.previous, + agentGuideText: composition.agentGuideText + ) + } + // **The root commit is not split** (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". Splitting a repository's first commit three ways by the @@ -392,13 +473,7 @@ public final class GitAutoCommitter { guard !reading.isUnborn else { return [PlannedCommit( paths: changed.map(\.path), - message: input.composer.message(for: CommitMessageRequest( - boardRoot: input.boardRoot, - changedPaths: changed, - authorship: .user, - isRootCommit: true, - snapshot: input.snapshot - )), + message: input.composer.message(for: request(changed, .user, isRootCommit: true)), author: user, committer: user )] @@ -424,13 +499,7 @@ public final class GitAutoCommitter { if case let .foreign(identity) = authorship { author = identity } else { author = user } return PlannedCommit( paths: group.paths.map(\.path), - message: input.composer.message(for: CommitMessageRequest( - boardRoot: input.boardRoot, - changedPaths: group.paths, - authorship: authorship, - isRootCommit: false, - snapshot: input.snapshot - )), + message: input.composer.message(for: request(group.paths, authorship)), author: author, committer: user ) diff --git a/Kanban/Git/GitCommitOperation.swift b/Kanban/Git/GitCommitOperation.swift index a7a9689..dc76637 100644 --- a/Kanban/Git/GitCommitOperation.swift +++ b/Kanban/Git/GitCommitOperation.swift @@ -90,10 +90,21 @@ public struct GitChangedPath: Sendable, Equatable, Hashable { /// deletion on disk that the window must not be demoted by. public let isRename: Bool - public init(path: String, isDeletion: Bool, isRename: Bool) { + /// Whether the path is **new in this commit** — git's own `GIT_DELTA_ADDED` (and a rename's + /// arriving end), surfaced rather than inferred. + /// + /// It exists for the comment verb family (01-storage-format.md § Enhanced schema): comments are + /// window-scoped and the board snapshot never carries them, so "Comment on 'X'" and "Edit comment + /// on 'X'" cannot be told apart by a diff of two snapshots — the only thing that distinguishes a + /// comment folder arriving from one being rewritten is whether HEAD already had it, which is + /// exactly the question this diff already answered. + public let isArrival: Bool + + public init(path: String, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) { self.path = path self.isDeletion = isDeletion self.isRename = isRename + self.isArrival = isArrival } } @@ -331,7 +342,7 @@ enum GitCommitOperation { var found: [String: GitChangedPath] = [:] - func record(_ path: String?, isDeletion: Bool, isRename: Bool) { + func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) { guard let path, !path.isEmpty else { return } let existing = found[path] found[path] = GitChangedPath( @@ -339,7 +350,10 @@ enum GitCommitOperation { // Present wins where two deltas disagree: staging asks "is it there now", and the // `modified-by` demotion must not fire for a file the window ends with. isDeletion: (existing?.isDeletion ?? true) && isDeletion, - isRename: (existing?.isRename ?? false) || isRename + isRename: (existing?.isRename ?? false) || isRename, + // New wins, for the mirror of that reason: one delta calling a path an addition is + // enough to know HEAD did not have it, which is the whole content of the bit. + isArrival: (existing?.isArrival ?? false) || isArrival ) } @@ -350,9 +364,12 @@ enum GitCommitOperation { record(string(delta.old_file.path), isDeletion: true, isRename: false) case GIT_DELTA_RENAMED: // Both ends, and neither is a deletion the window may be demoted by: the departure - // has to leave the index and the arrival has to enter it. + // has to leave the index and the arrival has to enter it. The arriving end is new at + // its path, which is what the comment family reads a post by. record(string(delta.old_file.path), isDeletion: true, isRename: true) - record(string(delta.new_file.path), isDeletion: false, isRename: true) + record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true) + case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED: + record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: true) default: record(string(delta.new_file.path), isDeletion: false, isRename: false) } diff --git a/Kanban/Git/GitHeadSnapshot.swift b/Kanban/Git/GitHeadSnapshot.swift new file mode 100644 index 0000000..80350c1 --- /dev/null +++ b/Kanban/Git/GitHeadSnapshot.swift @@ -0,0 +1,158 @@ +import Foundation +import libgit2 +import os + +// MARK: - GitHeadSnapshot + +/// **The last-committed half of the composer's diff** (06-history-undo.md ▸ Commit messages: "a +/// structural diff of two board snapshots — last-committed vs. current"). +/// +/// ### Why HEAD's tree, and not a snapshot carried forward +/// +/// The engine could remember the board it committed last time and hand that back as "previous". It +/// deliberately does not, for four reasons, each of which is a case the carried value would get +/// wrong: +/// +/// - **Launch catch-up has no previous to carry.** "Changes found pending at board open diff HEAD's +/// tree against the working tree through the same composer" (06) — at open the app's only snapshot +/// is the one it just loaded, which already *contains* the pending changes. The previous state +/// exists nowhere but in the repository. +/// - **The app is not the only writer.** An agent that commits its own work moves HEAD without the +/// app writing anything; a carried snapshot would diff against a state that is already history. +/// - **A failed or skipped commit does not advance history.** A carried value would advance anyway +/// and silently under-describe the next window. +/// - **It is checkable.** "Last committed" is a fact the repository answers; a carried value is a +/// claim the engine makes about itself, and nothing would ever catch it drifting. +/// +/// The cost is this file: HEAD's tree is materialized into a temporary directory and read back +/// through the one `BoardLoader`, so the previous snapshot is produced by exactly the machinery that +/// produced the current one. Re-parsing rather than re-deriving is the point — two loaders would be +/// two definitions of what a board is. +/// +/// ### What it writes +/// +/// **`index.md` blobs in full; every other blob as a zero-byte placeholder.** The snapshot models +/// frontmatter, bodies and *attachment names* — never attachment bytes — so materializing a board's +/// images would copy megabytes per commit to answer a question about file names. Directories are +/// created so the shape the loader walks is the shape HEAD has. +/// +/// ### Isolation +/// +/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in +/// the same synchronous scope, and no handle crosses an `await`. Called from inside the flush's +/// detached task, never from the main actor. +enum GitHeadSnapshot { + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") + + /// libgit2's global state — `GitCommitOperation.startUp`'s twin and for its reason (a flush can + /// run when no `Repository` is alive, so this file cannot ride on SwiftGitX's refcount). + private static let startUp: Bool = { + git_libgit2_init() >= 0 + }() + + /// **The board as HEAD has it**, or `nil` when there is nothing to read one from: an unborn HEAD, + /// an unopenable repository, a tree with no board `index.md` in it. + /// + /// `nil` is a *shrug*, not an error — the composer that receives it simply has no previous half + /// and falls back to describing the commit by its paths. Nothing here can fail a commit. + nonisolated static func load(at boardRoot: URL) -> BoardModel? { + _ = startUp + guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil } + var repository: OpaquePointer? + guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return nil } + defer { git_repository_free(repository) } + guard git_repository_head_unborn(repository) != 1 else { return nil } + + var reference: OpaquePointer? + guard git_repository_head(&reference, repository) == 0, let reference else { return nil } + defer { git_reference_free(reference) } + var object: OpaquePointer? + guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0, let tree = object else { + return nil + } + defer { git_tree_free(tree) } + + let scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("LaneworkHeadSnapshot-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: scratch) } + guard (try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)) != nil + else { return nil } + + materialize(tree: tree, in: repository, into: scratch, depth: 0) + + do { + return try BoardLoader.load(boardRoot: scratch).model + } catch { + // A HEAD whose tree the loader refuses — a board committed before `index.md` existed, a + // schema from the future — is simply not a previous snapshot. The window still commits; + // its message is composed from the paths alone. + logger.debug("HEAD's tree did not load as a board: \(error.description, privacy: .public)") + return nil + } + } + + /// One tree level, recursively. Total and silent: a blob that cannot be read is skipped, because + /// a partial previous snapshot degrades one event's wording while a thrown error would cost the + /// commit its message entirely. + /// + /// The depth cap is a guard against a pathological repository, not a statement about boards — a + /// board is three levels deep, four counting `attachments/`. + private static func materialize( + tree: OpaquePointer, + in repository: OpaquePointer, + into directory: URL, + depth: Int + ) { + guard depth < 8 else { return } + let manager = FileManager.default + + for position in 0.. 0) + ? Data(bytes: bytes!, count: size) + : Data() + try? data.write(to: file) + + default: + // Submodules and symlinks: neither is a board, and neither is followed anywhere else + // in this app either (`BoardLoader.directoryCandidates` excludes links). + continue + } + } + } +} diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index 49ff7a0..b2e8605 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -103,7 +103,8 @@ struct AutoCommitDebounceTests { #expect(committer.commitCount == 1) #expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it") - #expect(try headSubject(at: fixture.root) == InterimCommitMessage.fallbackSubject) + // The message is the semantic composer's, end to end — no placeholder anywhere in the path. + #expect(try headSubject(at: fixture.root) == "Add card 'Second'") } @Test("A burst of changes inside one window is one commit, not one per change") @@ -893,6 +894,151 @@ struct EditSessionBoundaryTests { } } +// MARK: - Semantic messages, through the whole engine + +/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is +/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2, +/// carries the composed message — HEAD's tree read for the last-committed half, the working tree for +/// the current one, the split's own paths narrowing each message to its own commit. +@MainActor +@Suite("Auto-commit ▸ semantic messages") +struct AutoCommitMessageTests { + + @Test("A foreign change composes identically to an app-mediated one — only the author differs") + func originIsNotInTheProse() async throws { + // 06 ▸ The external gap, closed: "Origin lives in the author field (structural attribution), + // not in message prose." Two boards, the same rename, one vouched for and one not. + func rename(vouchedFor: Bool) async throws -> CommitRecord { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + let text = plain(order: "1024", title: "Renamed") + try fixture.item("\(Ident.lane1)/\(Ident.card1)", text) + if vouchedFor { + ledger.recordWrite( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)") + .appendingPathComponent(BoardLoader.indexFileName), + text: text + ) + committer.noteWriteBracketClosed() + } else { + committer.noteReloadLanded(sawForeignChange: true) + } + await committer.flushNow() + return try #require(try history(at: fixture.root).first) + } + + let app = try await rename(vouchedFor: true) + let foreign = try await rename(vouchedFor: false) + #expect(app.subject == "Rename card 'First' → 'Renamed'") + #expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design") + // …and the author is the only thing that differs. + #expect(app.authorEmail != CommitAttribution.externalAuthorEmail) + #expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail) + } + + @Test("Board-open catch-up carries a real composed message, not a placeholder") + func launchCatchUpComposes() async throws { + // "Changes found pending at board open diff HEAD's tree against the working tree through the + // same composer, instead of committing blind" (06). Nothing signals this window: no reload + // landed, no bracket closed, and no snapshot was ever handed to the committer — the previous + // board can only have come from HEAD. + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed")) + committer.start() + + try await waitUntil { committer.commitCount == 1 } + #expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'") + #expect(isClean(at: fixture.root)) + } + + @Test("The guide write auto-commits as 'Update agent guide (vN)'") + func theGuideComposesItsVersion() async throws { + // The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker + // line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site. + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + // An older guide, committed — so the window is a genuine guide *upgrade*: HEAD's bytes carry + // v1 and the working tree's carry the version this build ships. + try fixture.file(AgentGuide.filename, Data("\nOld.\n".utf8)) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + #expect(try headSubject(at: fixture.root) == "Update agent guide (v1)") + + _ = try AgentGuide.install(atBoardRoot: fixture.root) + committer.noteWriteBracketClosed() + await committer.flushNow() + + #expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))") + } + + @Test("A split window's two commits each describe only their own paths") + func eachCommitDescribesItsOwnPaths() async throws { + // Both messages compose against the same HEAD, so the only thing that can keep them apart is + // the changed-path list each commit stages — the filter, proved end to end. + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent")) + + let text = plain(order: "2048", title: "Added by the user") + let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text) + ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text) + + committer.noteWriteBracketClosed() + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + let log = try history(at: fixture.root) + #expect(committer.commitCount == 2) + // Newest first: the user's overwrite lands after the foreign version it might have buried. + #expect(log.first?.subject == "Add card 'Added by the user'") + #expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'") + #expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail) + } + + @Test("A comment lands in the trail by its own verb, through real libgit2") + func commentsComposeTheirFamily() async throws { + // The one part of the comment family that cannot be proved without a repository: "is this + // path new" is `GIT_DELTA_ADDED`, read off the real diff — the fact that tells a post from an + // edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema). + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001" + + try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8)) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + #expect(try headSubject(at: fixture.root) == "Comment on 'First'") + + try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8)) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + #expect(try headSubject(at: fixture.root) == "Edit comment on 'First'") + } + + @Test("A stray-only window names the stray rather than shrugging") + func straysAreNamed() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + try fixture.file("notes.txt", Data("scratch\n".utf8)) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + #expect(try headSubject(at: fixture.root) == "Update 'notes.txt'") + } +} + // MARK: - Attribution, as a pure function @Suite("Auto-commit ▸ attribution rules") diff --git a/KanbanTests/CommitMessageTests.swift b/KanbanTests/CommitMessageTests.swift new file mode 100644 index 0000000..29ce668 --- /dev/null +++ b/KanbanTests/CommitMessageTests.swift @@ -0,0 +1,824 @@ +import Foundation +import Testing +@testable import Kanban + +/// **The semantic commit-message engine** (06-history-undo.md ▸ Commit messages) — the vocabulary, +/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external +/// gap the section exists to close. +/// +/// **No repository anywhere in this file.** The composer is "a pure, testable function": two board +/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and +/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a +/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path +/// list is derived by comparing the two trees — which is what git would have reported, arrived at +/// without git. The engine-level half — that a real commit carries this message, by the real author — +/// lives in `AutoCommitTests`. + +// MARK: - Fixtures + +/// The board both snapshots start as: three lanes, two cards in the first. +private func baseBoard(_ fixture: WriterFixture) throws { + try fixture.board(title: "Board") + try fixture.lane(Ident.lane1, order: "1024", title: "Todo") + try fixture.lane(Ident.lane2, order: "2048", title: "Doing") + try fixture.lane(Ident.lane3, order: "3072", title: "Done") + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login") + try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it") +} + +/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one +/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader +/// would rightly withhold. +private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + +/// Every file under a root, keyed by its board-root-relative path in git's spelling. +private func files(under root: URL) -> [String: Data] { + var found: [String: Data] = [:] + let manager = FileManager.default + guard let walker = manager.enumerator(atPath: root.path) else { return found } + for case let relative as String in walker { + let url = root.appendingPathComponent(relative) + var isDirectory: ObjCBool = false + guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue + else { continue } + found[relative] = (try? Data(contentsOf: url)) ?? Data() + } + return found +} + +/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees — a plain +/// content comparison, since nothing here has a repository to ask. +private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] { + let old = files(under: before) + let new = files(under: after) + var paths: [GitChangedPath] = [] + for (path, data) in new where old[path] != data { + // `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it". + paths.append(GitChangedPath( + path: path, + isDeletion: false, + isRename: false, + isArrival: old[path] == nil + )) + } + for path in old.keys where new[path] == nil { + paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false)) + } + return paths.sorted { $0.path < $1.path } +} + +/// Builds the same board twice, changes one copy, and composes the message for the difference. +/// +/// - Parameters: +/// - board: the state both snapshots start in. +/// - change: what happened — applied to the "after" copy only. +/// - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there** +/// because 06 rules that it must not matter: "a foreign move reads 'Move card …' exactly like an +/// app-mediated one". The one suite that varies it proves exactly that. +/// - renames: paths the survey would have paired as renames, for the shapes that turn on it. +private func compose( + board: (WriterFixture) throws -> Void = baseBoard, + change: (WriterFixture) throws -> Void, + authorship: CommitAuthorship = .user, + renames: Set = [], + guideText: String? = nil +) throws -> String { + let before = try WriterFixture() + defer { before.tearDown() } + let after = try WriterFixture() + defer { after.tearDown() } + + try board(before) + try board(after) + try change(after) + + let paths = changedPaths(from: before.root, to: after.root).map { path in + renames.contains(path.path) + ? GitChangedPath( + path: path.path, + isDeletion: path.isDeletion, + isRename: true, + isArrival: path.isArrival + ) + : path + } + return CommitMessageEngine.message(for: CommitMessageRequest( + boardRoot: after.root, + changedPaths: paths, + authorship: authorship, + isRootCommit: false, + snapshot: try after.snapshot(), + previousSnapshot: try before.snapshot(), + agentGuideText: guideText + )) +} + +private func subject(of message: String) -> String { + String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "") +} + +private func body(of message: String) -> [String] { + let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard lines.count > 2 else { return [] } + return Array(lines.dropFirst(2)) +} + +// MARK: - One event, one subject + +@Suite("Commit messages ▸ a single event is the subject") +struct CommitMessageSingleEventTests { + + @Test("Adding a card names the card, with its destination as the detail") + func addingACard() throws { + let message = try compose { fixture in + try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests") + } + #expect(subject(of: message) == "Add card 'Write tests'") + #expect(body(of: message) == ["to Doing"]) + } + + @Test("A cross-lane move reads as a move — never delete plus add") + func movingACard() throws { + // The whole-board id match: the folder left one lane and arrived in another, and the + // composer is asked to tell that from a deletion beside an arrival. + let message = try compose { fixture in + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)") + } + #expect(subject(of: message) == "Move card 'Fix login' to Done") + #expect(body(of: message) == ["Todo → Done"]) + } + + @Test("A rename carries both names in the subject — 06's own form") + func renamingALane() throws { + let message = try compose { fixture in + try fixture.lane(Ident.lane1, order: "1024", title: "Backlog") + } + #expect(message == "Rename lane 'Todo' → 'Backlog'") + } + + @Test("An edited body is an Edit, and a restyle is a Restyle") + func editingAndRestyling() throws { + let edited = try compose { fixture in + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.") + } + #expect(edited == "Edit card 'Fix login'") + + let restyled = try compose { fixture in + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n" + ) + } + #expect(restyled == "Restyle card 'Fix login'") + } + + @Test("A widened lane is a Resize") + func resizingALane() throws { + let message = try compose { fixture in + try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2) + } + #expect(message == "Resize lane 'Todo' to 2×") + } + + @Test("An attachment composes Attach, named by the card it landed on") + func attachingAFile() throws { + let message = try compose { fixture in + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8)) + } + #expect(message == "Attach 'spec.pdf' to card 'Fix login'") + } + + @Test("A removed attachment composes Remove") + func removingAFile() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8)) + } change: { fixture in + try FileManager.default.removeItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf") + ) + } + #expect(message == "Remove 'spec.pdf' from card 'Fix login'") + } + + @Test("A repositioned card composes Reorder, naming its lane") + func reorderingCards() throws { + // A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else + // changes. "An order change that repositions an item among its siblings composes Reorder." + let message = try compose { fixture in + try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login") + } + #expect(message == "Reorder cards in Todo") + } + + @Test("The board's own fields compose board events") + func boardLevelEvents() throws { + let renamed = try compose { fixture in + try fixture.board(title: "Q3 Plan") + } + #expect(renamed == "Rename board 'Board' → 'Q3 Plan'") + + let described = try compose { fixture in + try fixture.board(title: "Board", body: "What this board is for.") + } + #expect(described == "Edit board description") + } +} + +// MARK: - The trash pair + +@Suite("Commit messages ▸ the trash pair, by diff shape alone") +struct CommitMessageTrashTests { + + @Test("A move into .trash/ is Delete") + func intoTheTrashIsDelete() throws { + let message = try compose { fixture in + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) + } + #expect(message == "Delete card 'Fix login'") + } + + @Test("A move out of .trash/ is Restore") + func outOfTheTrashIsRestore() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea") + } change: { fixture in + try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3) + } + #expect(message == "Restore card 'Old idea'") + } + + @Test("Leaving the tree entirely is Permanently delete") + func leavingTheTreeIsPermanent() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea") + } change: { fixture in + try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)")) + } + #expect(message == "Permanently delete card 'Old idea'") + } + + @Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete") + func emptyTrashFolds() throws { + let purge = try compose { fixture in + try baseBoard(fixture) + try fixture.trashCard(Ident.card3, order: "1024", title: "One") + try fixture.trashCard(Ident.card4, order: "2048", title: "Two") + try fixture.trashCard(spareIdentity, order: "3072", title: "Three") + } change: { fixture in + try FileManager.default.removeItem(at: fixture.url(".trash")) + } + #expect(subject(of: purge) == "Permanently delete 3 cards") + + let delete = try compose { fixture in + try baseBoard(fixture) + try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third") + } change: { fixture in + for card in [Ident.card1, Ident.card2, Ident.card3] { + try fixture.move("\(Ident.lane1)/\(card)", toTrash: card) + } + } + #expect(subject(of: delete) == "Delete 3 cards") + } + + @Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets") + func aTrashedLaneKeepsTheSubject() throws { + // "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'." And the cards are + // *deleted*, not permanently deleted: they went into the trash inside their lane's folder. + let message = try compose { fixture in + try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)") + } + #expect(subject(of: message) == "Delete lane 'Todo'") + #expect(body(of: message) == [ + "- Delete lane 'Todo'", + "- Delete card 'Fix login'", + "- Delete card 'Ship it'", + ]) + } +} + +// MARK: - Folding + +@Suite("Commit messages ▸ folding") +struct CommitMessageFoldingTests { + + @Test("Several of one kind fold, and a shared destination survives into the subject") + func sharedDestinationsFold() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third") + } change: { fixture in + for card in [Ident.card1, Ident.card2, Ident.card3] { + try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)") + } + } + #expect(subject(of: message) == "Move 3 cards to Done") + #expect(body(of: message).count == 3) + #expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done") + } + + @Test("Destinations that disagree drop out of the subject rather than lying") + func disagreeingDestinationsDropOut() throws { + let message = try compose(change: { fixture in + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)") + }) + #expect(subject(of: message) == "Move 2 cards") + } + + @Test("A genuinely mixed window is 'Update board' — with every event in the body") + func mixedWindowsFallBack() throws { + let message = try compose { fixture in + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout") + try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests") + } + #expect(subject(of: message) == CommitMessageEngine.mixedSubject) + #expect(body(of: message) == [ + "- Add card 'Write tests' to Doing", + "- Rename card 'Fix login' → 'Fix logout'", + ]) + } + + @Test("A deleted lane's cards are body bullets, never the subject") + func impliedEventsDoNotStealTheSubject() throws { + let message = try compose { fixture in + try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) + } + #expect(subject(of: message) == "Permanently delete lane 'Todo'") + #expect(body(of: message) == [ + "- Permanently delete lane 'Todo'", + "- Permanently delete card 'Fix login'", + "- Permanently delete card 'Ship it'", + ]) + } +} + +// MARK: - The external gap + +@Suite("Commit messages ▸ the external gap, closed") +struct CommitMessageExternalSurfaceTests { + + @Test("Labels, assignees and due dates each compose a named subject") + func theReservedTrioComposes() throws { + func withKey(_ line: String) throws -> String { + try compose { fixture in + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n" + ) + } + } + #expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'") + #expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'") + #expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'") + } + + @Test("An unmodeled custom key composes a named generic — never a board-level shrug") + func customKeysComposeANamedGeneric() throws { + let message = try compose { fixture in + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n" + ) + } + // Two custom keys, one event: the item is what is named, not the keys. + #expect(message == "Update card 'Fix login'") + } + + @Test("A foreign change composes identically to an app-mediated one") + func originIsNotInTheProse() throws { + // "Origin lives in the author field (structural attribution), not in message prose — a foreign + // move reads 'Move card …' exactly like an app-mediated one." Same change, all three classes. + func move(_ authorship: CommitAuthorship) throws -> String { + try compose(change: { fixture in + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") + }, authorship: authorship) + } + let app = try move(.user) + let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "external@lanework.invalid"))) + let heal = try move(.heal) + #expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing") + #expect(foreign == app) + #expect(heal == app) + } +} + +// MARK: - Bookkeeping + +@Suite("Commit messages ▸ bookkeeping composes nothing") +struct CommitMessageBookkeepingTests { + + @Test("A bumped modified stamp is not an event") + func stampsAreSilent() throws { + let message = try compose { fixture in + try fixture.card( + Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", + modified: "2026-07-31T12:00:00Z" + ) + } + #expect(message == CommitMessageEngine.mixedSubject) + } + + @Test("A renumber's rescale preserves sequence and composes nothing") + func rescalesAreSilent() throws { + // Every rank multiplied, nobody repositioned — 01-storage-format.md's renumber. "Sequence is + // what the diff compares, not raw `order` values." + let message = try compose { fixture in + try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login") + try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it") + } + #expect(message == CommitMessageEngine.mixedSubject) + } + + @Test("An on-touch heal's backfilled kind is not an event") + func theBackfilledKindIsSilent() throws { + let message = try compose { fixture in + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n" + ) + } + #expect(message == CommitMessageEngine.mixedSubject) + } + + @Test("A renumber batches with the insert that triggered it") + func aRenumberRidesWithItsInsert() throws { + // Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit + // reads as the insert, because the *sequence* of the cards that were already there is + // unchanged. + let message = try compose { fixture in + try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login") + try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in") + try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it") + } + #expect(subject(of: message) == "Add card 'Wedged in'") + } +} + +// MARK: - Titles + +@Suite("Commit messages ▸ titles") +struct CommitMessageTitleTests { + + @Test("Titles truncate in subjects only; bodies carry them whole") + func truncationIsSubjectOnly() throws { + let long = String(repeating: "A", count: 60) + let message = try compose { fixture in + try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long) + try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long) + } + #expect(subject(of: message) == "Add 2 cards to Doing") + #expect(body(of: message).allSatisfy { $0.contains(long) }) + + let sole = try compose { fixture in + try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long) + } + #expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'") + } + + @Test("An untitled item reads (untitled), never a bare pair of quotes") + func untitledIsRendered() throws { + let message = try compose { fixture in + try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n") + } + #expect(message == "Add card '(untitled)'\n\nto Doing") + #expect(!message.contains("''")) + } +} + +// MARK: - Non-snapshot paths + +@Suite("Commit messages ▸ non-snapshot files") +struct CommitMessagePathEventTests { + + @Test("A stray composes its own path-shaped event, and several fold") + func straysComposePathEvents() throws { + let one = try compose { fixture in + try fixture.file("notes.txt", Data("hello".utf8)) + } + #expect(one == "Update 'notes.txt'") + + let several = try compose { fixture in + try fixture.file("notes.txt", Data("hello".utf8)) + try fixture.file(".gitignore", Data("*.tmp\n".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8)) + } + #expect(subject(of: several) == "Update 3 files") + #expect(body(of: several) == [ + "- Update '.gitignore'", + "- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'", + "- Update 'notes.txt'", + ]) + } + + @Test("The agent guide composes its version, read from the marker line") + func theAgentGuideComposesItsVersion() throws { + let guide = "\nHow to work in this board.\n" + let message = try compose(change: { fixture in + try fixture.file(AgentGuide.filename, Data(guide.utf8)) + }, guideText: guide) + #expect(message == "Update agent guide (v7)") + } + + @Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is") + func amarkerlessGuideIsJustAFile() throws { + let text = "My own notes for agents.\n" + let message = try compose(change: { fixture in + try fixture.file(AgentGuide.filename, Data(text.utf8)) + }, guideText: text) + #expect(message == "Update 'CLAUDE.md'") + } + + @Test("Model events keep the subject; a stray rides along as a bullet") + func modelEventsKeepTheSubject() throws { + let message = try compose { fixture in + try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests") + try fixture.file("notes.txt", Data("hello".utf8)) + } + #expect(subject(of: message) == "Add card 'Write tests'") + #expect(body(of: message) == [ + "- Add card 'Write tests' to Doing", + "- Update 'notes.txt'", + ]) + } + + @Test("A path this commit does not stage composes nothing — the stage-around, in the message") + func pathsOutsideTheCommitAreNotDescribed() throws { + // The split window's rule, and the open-Edit-session exclusion's: the composer describes the + // commit it is composing for, not everything that differs from HEAD. + let before = try WriterFixture() + defer { before.tearDown() } + let after = try WriterFixture() + defer { after.tearDown() } + try baseBoard(before) + try baseBoard(after) + try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.") + try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests") + + let staged = changedPaths(from: before.root, to: after.root) + .filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") } + let message = CommitMessageEngine.message(for: CommitMessageRequest( + boardRoot: after.root, + changedPaths: staged, + authorship: .user, + isRootCommit: false, + snapshot: try after.snapshot(), + previousSnapshot: try before.snapshot() + )) + #expect(message == "Add card 'Write tests'\n\nto Doing") + } +} + +// MARK: - The comment verb family + +@Suite("Commit messages ▸ the comment verb family") +struct CommitMessageCommentTests { + + private static let commentA = "cccccccc-0000-4000-8000-000000000001" + private static let commentB = "cccccccc-0000-4000-8000-000000000002" + private static let commentC = "cccccccc-0000-4000-8000-000000000003" + + /// A comment's `index.md` — never parsed by the composer, which reads the family off path shape + /// alone ("comments are window-scoped, outside the board snapshot" — 01-storage-format.md). + private static func commentText(_ body: String) -> Data { + Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8) + } + + private static func thread(_ card: String, _ entry: String) -> String { + "\(Ident.lane1)/\(card)/comments/\(entry)/index.md" + } + + @Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'") + func postingComposesComment() throws { + // The app's post is one rename: `comments/.draft/` → `comments//`, restamped in the + // same bracket. The departing `.draft` end is silent under the rename rule; the arrival is + // the event. + let draft = Self.thread(Ident.card1, ".draft") + let posted = Self.thread(Ident.card1, Self.commentA) + let message = try compose(board: { fixture in + try baseBoard(fixture) + try fixture.file(draft, Self.commentText("Half a thought.")) + }, change: { fixture in + try fixture.file(posted, Self.commentText("Half a thought.")) + try FileManager.default.removeItem( + at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft") + ) + }, renames: [draft, posted]) + #expect(message == "Comment on 'Fix login'") + } + + @Test("A foreign writer's new comment folder composes the same words") + func foreignArrivalsComposeTheSame() throws { + // No rename to read: an agent simply wrote a folder. Origin-agnostic one level down. + let message = try compose { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent.")) + } + #expect(message == "Comment on 'Fix login'") + } + + @Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'") + func editsComposeEditComment() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft.")) + } change: { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts.")) + } + #expect(message == "Edit comment on 'Fix login'") + } + + @Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival") + func deletionComposesDeleteComment() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted.")) + } change: { fixture in + try fixture.moveFolder( + "\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)", + to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)" + ) + } + // Both halves are in the window and git paired neither of them; the arriving end speaks. + #expect(message == "Delete comment on 'Fix login'") + } + + @Test("The close purge composes 'Permanently delete comment on ⟨card⟩'") + func purgeComposesPermanentDelete() throws { + // `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in + // DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug. + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file( + "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md", + Self.commentText("Deleted a while ago.") + ) + } change: { fixture in + try FileManager.default.removeItem( + at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash") + ) + } + #expect(message == "Permanently delete comment on 'Fix login'") + } + + @Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'") + func draftSavesComposeDraftComment() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing")) + } change: { fixture in + try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more")) + } + #expect(message == "Draft comment on 'Fix login'") + } + + @Test("Every file in one comment folder is one event") + func oneCommentIsOneEvent() throws { + let message = try compose { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file.")) + try fixture.file( + "\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png", + Data("png".utf8) + ) + } + #expect(message == "Comment on 'Fix login'") + } + + @Test("Several comments fold on the card they landed on") + func commentsFoldOnTheirCard() throws { + let sameCard = try compose { fixture in + for comment in [Self.commentA, Self.commentB, Self.commentC] { + try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note.")) + } + } + #expect(subject(of: sameCard) == "3 comments on 'Fix login'") + + let twoCards = try compose { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note.")) + try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note.")) + } + #expect(subject(of: twoCards) == "Comment on 2 cards") + } + + @Test("The card's title is resolved from the snapshot, untitled included") + func titlesResolveFromTheSnapshot() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n") + } change: { fixture in + try fixture.file( + "\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md", + Self.commentText("On a nameless card.") + ) + } + #expect(message == "Comment on '(untitled)'") + } + + @Test("A comment-only window never shrugs and never shows a raw path") + func aCommentOnlyWindowGetsItsOwnSubject() throws { + let message = try compose { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note.")) + } + #expect(message != CommitMessageEngine.mixedSubject) + #expect(!message.contains("comments/")) + } + + @Test("Model events keep the subject; a comment rides along as a bullet") + func modelEventsOutrankComments() throws { + let message = try compose { fixture in + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)") + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note.")) + } + #expect(subject(of: message) == "Move card 'Ship it' to Done") + #expect(body(of: message).contains("- Comment on 'Fix login'")) + } + + @Test("A card carrying its thread into the trash is one event, not one per comment") + func aMovedCardSwallowsItsThread() throws { + // The implied-events rule, one level down: the comment files travelled because the card did. + let message = try compose { fixture in + try baseBoard(fixture) + for comment in [Self.commentA, Self.commentB] { + try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note.")) + } + } change: { fixture in + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) + } + #expect(message == "Delete card 'Fix login'") + } +} + +// MARK: - Repair + +@Suite("Commit messages ▸ Repair") +struct CommitMessageRepairTests { + + /// The remint's exact shape, assembled rather than provoked. + /// + /// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to + /// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the + /// arrival — because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`), + /// its id was never in a board model at all. Provoking that by writing two folders with one id + /// would make the test depend on which twin the dedupe happened to keep; the shape the composer + /// is asked about is this, and it is stated directly. + private func remintMessage(authorship: CommitAuthorship) throws -> String { + let before = try WriterFixture() + defer { before.tearDown() } + let after = try WriterFixture() + defer { after.tearDown() } + try baseBoard(before) + try baseBoard(after) + try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login") + + let departed = "\(Ident.lane1)/\(Ident.card3)/index.md" + let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md" + let paths = changedPaths(from: before.root, to: after.root).map { path in + path.path == arrived + ? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true) + : path + } + [GitChangedPath(path: departed, isDeletion: true, isRename: true)] + + return CommitMessageEngine.message(for: CommitMessageRequest( + boardRoot: after.root, + changedPaths: paths, + authorship: authorship, + isRootCommit: false, + snapshot: try after.snapshot(), + previousSnapshot: try before.snapshot() + )) + } + + @Test("A heal window's folder remint reads as Repair, not as an arrival") + func theRemintComposesRepair() throws { + #expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'") + } + + @Test("The same shape outside a heal window is an ordinary arrival") + func repairIsHealOnly() throws { + #expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'") + } +} + +// MARK: - The root commit + +@Suite("Commit messages ▸ the root commit") +struct CommitMessageRootCommitTests { + + @Test("The repository's first commit has the one fixed subject") + func theRootCommitIsFixed() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try baseBoard(fixture) + let message = CommitMessageEngine.message(for: CommitMessageRequest( + boardRoot: fixture.root, + changedPaths: changedPaths(from: fixture.root, to: fixture.root), + authorship: .user, + isRootCommit: true, + snapshot: try fixture.snapshot(), + previousSnapshot: nil + )) + #expect(message == GitRepository.initialCommitSubject) + } +} diff --git a/README.md b/README.md index abadcff..542ef82 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Lanework is in early development. This list tracks what has actually shipped and - **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. -- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. Messages read "Update board" for now — the semantic message engine ("Move card 'Fix login' to Doing") is the next card. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes. +- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes. ## Development