From ae7be98eaa401959c77a1950d261df3b1107ddae Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 10:38:20 -0400 Subject: [PATCH] =?UTF-8?q?The=20message=20engine=20outlives=20its=20subst?= =?UTF-8?q?rate=20=E2=80=94=20harvested=20to=20Kanban/Changes/=20as=20the?= =?UTF-8?q?=20change=20narrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of strategy/01-git-excision.md: CommitMessageEngine and the composer seam relocate to a neutral module renamed away from commit vocabulary (ChangeNarrator, ChangeNarrationRequest, ChangeNarrating, SemanticChangeNarration, ChangeAuthorship), GitChangedPath extracts from GitCommitOperation as ChangedPath, and the one git tie severs — authorship's foreign case carries a display name, not a GitIdentity. The spec tests transplant as ChangeNarratorTests, alive until the journal work begins. 3,009 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- .../ChangeNarration.swift} | 53 +++++++++------ .../ChangeNarrator.swift} | 67 ++++++++++--------- Kanban/Changes/ChangedPath.swift | 53 +++++++++++++++ Kanban/Git/CommitAttribution.swift | 12 ++-- Kanban/Git/GitAutoCommitter.swift | 62 ++++++++--------- Kanban/Git/GitCommitOperation.swift | 56 ++-------------- KanbanTests/AutoCommitTests.swift | 20 +++--- ...eTests.swift => ChangeNarratorTests.swift} | 66 +++++++++--------- 8 files changed, 207 insertions(+), 182 deletions(-) rename Kanban/{Git/CommitMessageComposer.swift => Changes/ChangeNarration.swift} (74%) rename Kanban/{Git/CommitMessageEngine.swift => Changes/ChangeNarrator.swift} (96%) create mode 100644 Kanban/Changes/ChangedPath.swift rename KanbanTests/{CommitMessageTests.swift => ChangeNarratorTests.swift} (95%) diff --git a/Kanban/Git/CommitMessageComposer.swift b/Kanban/Changes/ChangeNarration.swift similarity index 74% rename from Kanban/Git/CommitMessageComposer.swift rename to Kanban/Changes/ChangeNarration.swift index 65b9b95..b7b3c37 100644 --- a/Kanban/Git/CommitMessageComposer.swift +++ b/Kanban/Changes/ChangeNarration.swift @@ -1,5 +1,10 @@ import Foundation +/// Harvested 2026-08-08 from `Kanban/Git/` per `strategy/01-git-excision.md`, with its git ties +/// severed — this is the designated core of the future activity feed / foreign-change journal. The +/// "previous snapshot" a narration request carries is supplied by the caller by contract: the git +/// stack supplied it from HEAD, and the journal will supply it from memory/snapshots. + // MARK: - Authorship /// Which of the three classes a commit is — the axis the message engine is allowed to know about. @@ -11,13 +16,13 @@ import Foundation /// composer card therefore gets the fact and is expected to ignore it for phrasing; having it means /// it never has to be plumbed later, and having it *named* means the rule about not using it has /// something to point at. -public enum CommitAuthorship: Sendable, Equatable { +public enum ChangeAuthorship: Sendable, Equatable { /// The user acting through the app. case user /// A scheduled heal's own commit (ruled 2026-07-29). case heal - /// Everything else, carrying the author it will be committed under. - case foreign(GitIdentity) + /// Everything else, carrying the display name it will be recorded under. + case foreign(String) } // MARK: - The request @@ -29,12 +34,12 @@ public enum CommitAuthorship: Sendable, Equatable { /// 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 +/// **Everything here is a value, and that is the design.** The composer (`ChangeNarrator`) 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 { +public struct ChangeNarrationRequest: Sendable { /// The board this is a commit in. public let boardRoot: URL @@ -42,10 +47,10 @@ public struct CommitMessageRequest: Sendable { /// **The changed-path list** (06 ▸ Commit messages ▸ Non-snapshot files commit too: "beside the /// snapshot diff it receives the changed-path list, and non-snapshot paths compose *path-shaped /// events*"), narrowed to the paths *this* commit stages. - public let changedPaths: [GitChangedPath] + public let changedPaths: [ChangedPath] /// Which class this commit is. - public let authorship: CommitAuthorship + public let authorship: ChangeAuthorship /// Whether this is the repository's first commit — the one commit with a subject of its own /// ("Initial board state", 06 ▸ Rules ▸ Abnormal repo states). @@ -58,12 +63,16 @@ public struct CommitMessageRequest: Sendable { /// 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`). + /// **The last-committed half** of the "last-committed vs. current" diff — supplied by the caller + /// by contract; the narrator itself never reads this from anywhere. The git stack supplied it from + /// HEAD's tree (`GitHeadSnapshot`), the former git-backed supplier; the journal will supply it from + /// memory/snapshots. /// - /// `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. + /// `nil` when the caller has nothing to diff against — the git stack's case was an unborn HEAD + /// (where `isRootCommit` already says everything) or a HEAD whose tree did not load as a board. The + /// git stack read it from the repository rather than carrying it 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 @@ -76,7 +85,7 @@ public struct CommitMessageRequest: Sendable { public let agentGuideText: String? /// **When each of this commit's comments was created** — keyed by the comment folder's - /// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it. + /// board-root-relative path, as `ChangeNarrator.commentFolder(of:)` spells it. /// /// The second value on this struct that a *file* has to be read for, and it is here for /// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically — by the @@ -94,8 +103,8 @@ public struct CommitMessageRequest: Sendable { public init( boardRoot: URL, - changedPaths: [GitChangedPath], - authorship: CommitAuthorship, + changedPaths: [ChangedPath], + authorship: ChangeAuthorship, isRootCommit: Bool, snapshot: BoardModel?, previousSnapshot: BoardModel? = nil, @@ -117,7 +126,7 @@ public struct CommitMessageRequest: Sendable { /// **What a commit says** (06-history-undo.md ▸ Commit messages). /// -/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable +/// The implementation is `SemanticChangeNarration` below, over `ChangeNarrator`: "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 @@ -126,23 +135,23 @@ public struct CommitMessageRequest: Sendable { /// `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 /// work is main-actor work. -public protocol CommitMessageComposing: Sendable { - func message(for request: CommitMessageRequest) -> String +public protocol ChangeNarrating: Sendable { + func narrative(for request: ChangeNarrationRequest) -> String } // MARK: - The wired composer /// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`). /// -/// A one-line conformance over `CommitMessageEngine`, deliberately: the vocabulary is worth a file of +/// A one-line conformance over `ChangeNarrator`, 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 struct SemanticChangeNarration: ChangeNarrating { public init() {} - public func message(for request: CommitMessageRequest) -> String { - CommitMessageEngine.message(for: request) + public func narrative(for request: ChangeNarrationRequest) -> String { + ChangeNarrator.narrative(for: request) } } diff --git a/Kanban/Git/CommitMessageEngine.swift b/Kanban/Changes/ChangeNarrator.swift similarity index 96% rename from Kanban/Git/CommitMessageEngine.swift rename to Kanban/Changes/ChangeNarrator.swift index 18635c3..448b3d7 100644 --- a/Kanban/Git/CommitMessageEngine.swift +++ b/Kanban/Changes/ChangeNarrator.swift @@ -1,6 +1,11 @@ import Foundation -// MARK: - CommitMessageEngine +/// Harvested 2026-08-08 from `Kanban/Git/` per `strategy/01-git-excision.md`, with its git ties +/// severed — this is the designated core of the future activity feed / foreign-change journal. The +/// "previous snapshot" this narrator diffs against is supplied by the caller by contract: the git +/// stack supplied it from HEAD, and the journal will supply it from memory/snapshots. + +// MARK: - ChangeNarrator /// **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. @@ -13,13 +18,13 @@ import Foundation /// 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 +/// prose" — which is why `ChangeAuthorship` 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 +/// guide's text arrive as values on `ChangeNarrationRequest`; `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. @@ -33,7 +38,7 @@ import Foundation /// 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 { +enum ChangeNarrator { /// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring /// the bare 'Update board' fallback"): @@ -91,7 +96,7 @@ enum CommitMessageEngine { // 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 { + static func narrative(for request: ChangeNarrationRequest) -> 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." @@ -105,7 +110,7 @@ enum CommitMessageEngine { /// 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] { + static func events(for request: ChangeNarrationRequest) -> [Event] { let changed = Set(request.changedPaths.map(\.path)) var model: [Event] = [] if let previous = request.previousSnapshot, let current = request.snapshot { @@ -122,7 +127,7 @@ enum CommitMessageEngine { /// - One event: its own subject, plus its detail line where it has one. /// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event — /// "so the oneline log stays scannable and the full message stays complete". - private static func assemble(_ events: [Event], request: CommitMessageRequest) -> String { + private static func assemble(_ events: [Event], request: ChangeNarrationRequest) -> String { guard !events.isEmpty else { return unnamedSubject } if events.count == 1 { guard let detail = events[0].detail else { return events[0].subject } @@ -168,7 +173,7 @@ enum CommitMessageEngine { /// path that belongs to no card at all — the board's own `index.md`, a lane, a stray, the agent /// guide — is by definition not one card's window, so a single `nil` answer decides the whole /// question. - private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? { + private static func sharedItem(of events: [Event], request: ChangeNarrationRequest) -> String? { var folder: String? for event in events { for path in event.paths { @@ -200,7 +205,7 @@ enum CommitMessageEngine { private static func modelEvents( from previous: BoardModel, to current: BoardModel, - request: CommitMessageRequest + request: ChangeNarrationRequest ) -> [Event] { let previousLanes = laneIndex(of: previous) let currentLanes = laneIndex(of: current) @@ -275,7 +280,7 @@ enum CommitMessageEngine { /// (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) } + var displayTitle: String { ChangeNarrator.title(title) } } private static func laneIndex(of board: BoardModel) -> [ItemID: LaneEntry] { @@ -429,7 +434,7 @@ enum CommitMessageEngine { let path: String let attachmentFolder: String - var displayTitle: String { CommitMessageEngine.title(card.title) } + var displayTitle: String { ChangeNarrator.title(card.title) } } private static func cardIndex(of board: BoardModel) -> [ItemID: CardEntry] { @@ -463,7 +468,7 @@ enum CommitMessageEngine { previousLanes: [ItemID: LaneEntry], currentLanes: [ItemID: LaneEntry], laneOutcomes: [Event], - authorship: CommitAuthorship, + authorship: ChangeAuthorship, renames: (arrivals: Set, departures: Set) ) -> [Event] { var events: [Event] = [] @@ -791,7 +796,7 @@ enum CommitMessageEngine { /// content replacement, named from the path alone: 'Replace attachment 'photo.png' — card 'X'', /// never the anonymous path generic". An unchanged listing is exactly "no model event claimed /// this path", so the rule needs no second question of the snapshot — see `replacedAttachment`. - private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] { + private static func pathEvents(for request: ChangeNarrationRequest, claimedBy model: [Event]) -> [Event] { let claimed = Set(model.flatMap(\.paths)) var comments: [String: CommentGroup] = [:] var events: [Event] = [] @@ -873,7 +878,7 @@ enum CommitMessageEngine { let comment: CommentPath /// This comment's own folder, board-root-relative — the key its `created` is looked up under - /// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads. + /// (`ChangeNarrationRequest.commentTimestamps`) and the name the chronology's tie-break reads. let folder: String var paths: [String] = [] @@ -893,7 +898,7 @@ enum CommitMessageEngine { folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder } - mutating func add(_ changed: GitChangedPath) { + mutating func add(_ changed: ChangedPath) { paths.append(changed.path) if changed.isArrival { hasArrival = true } if !changed.isDeletion { hasSurvivor = true } @@ -934,7 +939,7 @@ enum CommitMessageEngine { /// "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 + /// knows: whether HEAD had that path (`ChangedPath.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. @@ -947,7 +952,7 @@ enum CommitMessageEngine { private static func commentEvents( _ groups: [String: CommentGroup], model: [Event], - request: CommitMessageRequest + request: ChangeNarrationRequest ) -> [Event] { guard !groups.isEmpty else { return [] } let titles = cardTitlesByPath(request) @@ -1057,7 +1062,7 @@ enum CommitMessageEngine { /// 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] { + private static func cardTitlesByPath(_ request: ChangeNarrationRequest) -> [String: String] { var titles: [String: String] = [:] for board in [request.previousSnapshot, request.snapshot].compactMap({ $0 }) { for lane in board.lanes { @@ -1152,7 +1157,7 @@ enum CommitMessageEngine { /// 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))" } ?? "" + let target = destination.map { " to \(ChangeNarrator.truncated($0))" } ?? "" switch self { case .addCard: return "Add \(count) cards\(target)" case .deleteCard: return "Delete \(count) cards" @@ -1167,19 +1172,19 @@ enum CommitMessageEngine { case .dueCard: return "Set due date on \(count) cards" case .changeKeyCard: guard let destination else { return "Change custom keys on \(count) cards" } - return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))" + return "Change custom keys on card \(ChangeNarrator.quotedSubject(destination))" case .attachFile: guard let destination else { return "Attach \(count) files" } - return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))" + return "Attach \(count) files to card \(ChangeNarrator.quotedSubject(destination))" case .removeFile: guard let destination else { return "Remove \(count) files" } - return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))" + return "Remove \(count) files from card \(ChangeNarrator.quotedSubject(destination))" case .replaceFile: guard let destination else { return "Replace \(count) attachments" } - return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))" + return "Replace \(count) attachments — card \(ChangeNarrator.quotedSubject(destination))" case .reorderCards: guard let destination else { return "Reorder cards in \(count) lanes" } - return "Reorder cards in \(CommitMessageEngine.truncated(destination))" + return "Reorder cards in \(ChangeNarrator.truncated(destination))" case .repairDuplicate: return "Repair \(count) duplicates" case .addLane: return "Add \(count) lanes" @@ -1195,7 +1200,7 @@ enum CommitMessageEngine { case .dueLane: return "Set due date on \(count) lanes" case .changeKeyLane: guard let destination else { return "Change custom keys on \(count) lanes" } - return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))" + return "Change custom keys on lane \(ChangeNarrator.quotedSubject(destination))" // A board has one title, one description, one style — and a lane reorder is a single // whole-board event. None of these can actually recur; the switch stays exhaustive. @@ -1210,7 +1215,7 @@ enum CommitMessageEngine { // event — unreachable in practice, and count-less if it ever is. case .changeKeyBoard: guard let destination else { return "Change custom keys on board" } - return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))" + return "Change custom keys on board \(ChangeNarrator.quotedSubject(destination))" case .agentGuide: return "Update agent guide" case .updatePath: return "Update \(count) files" @@ -1221,16 +1226,16 @@ enum CommitMessageEngine { // count: "3 comments on 'X'". case .commentPosted: guard let destination else { return "Comment on \(count) cards" } - return "\(count) comments on \(CommitMessageEngine.quotedSubject(destination))" + return "\(count) comments on \(ChangeNarrator.quotedSubject(destination))" case .commentEdited: guard let destination else { return "Edit \(count) comments" } - return "Edit \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" + return "Edit \(count) comments on \(ChangeNarrator.quotedSubject(destination))" case .commentDeleted: guard let destination else { return "Delete \(count) comments" } - return "Delete \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" + return "Delete \(count) comments on \(ChangeNarrator.quotedSubject(destination))" case .commentPurged: guard let destination else { return "Permanently delete \(count) comments" } - return "Permanently delete \(count) comments on \(CommitMessageEngine.quotedSubject(destination))" + return "Permanently delete \(count) comments on \(ChangeNarrator.quotedSubject(destination))" // One draft per card, so several can only mean several cards. case .commentDrafted: return "Draft comment on \(count) cards" } @@ -1382,7 +1387,7 @@ enum CommitMessageEngine { /// Both ends of every rename libgit2 paired up in this commit, split by direction. private static func renamedPaths( - in request: CommitMessageRequest + in request: ChangeNarrationRequest ) -> (arrivals: Set, departures: Set) { var arrivals: Set = [] var departures: Set = [] diff --git a/Kanban/Changes/ChangedPath.swift b/Kanban/Changes/ChangedPath.swift new file mode 100644 index 0000000..f21fa3a --- /dev/null +++ b/Kanban/Changes/ChangedPath.swift @@ -0,0 +1,53 @@ +import Foundation + +// MARK: - Changed paths + +/// Harvested 2026-08-08 from `Kanban/Git/` per `strategy/01-git-excision.md`. This value type, and +/// the narrator it feeds, are the designated core of the future activity feed / foreign-change +/// journal — the "previous snapshot" input the narrator diffs against is supplied by the caller by +/// contract; the journal will supply it from memory/snapshots, where the git stack supplied it from +/// HEAD. + +/// One path found to differ between the last-recorded snapshot and the working tree (what `git +/// status` reports, for the git-backed supplier). +/// +/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or +/// removes exactly these, and attribution asks a question per *file* (06 ▸ Interaction with external +/// writers: "classify every observed change, per file"). +public struct ChangedPath: Sendable, Equatable, Hashable { + + /// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot). + public let path: String + + /// Whether the file is **gone** from the working tree. + /// + /// The `modified-by` rule turns on this bit — "any true deletion in the window falls back to + /// `Lanework External` — a deletion leaves no file to stamp" — which is why the rename half + /// below is a separate fact rather than folded in here. + public let isDeletion: Bool + + /// Whether this path is one end of a **rename** the provider paired up (libgit2's pairing, for + /// the git-backed supplier). + /// + /// "**A folder move is not a deletion**: items match by id across the whole board … so a moved + /// card attributes by its stamp like any changed file" (06). A paired departure is therefore a + /// deletion on disk that the window must not be demoted by. + public let isRename: Bool + + /// Whether the path is **new in this commit** — surfaced rather than inferred (git's own + /// `GIT_DELTA_ADDED`, for the git-backed supplier, and a rename's arriving end). + /// + /// 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 + } +} diff --git a/Kanban/Git/CommitAttribution.swift b/Kanban/Git/CommitAttribution.swift index f4e3e39..85a2536 100644 --- a/Kanban/Git/CommitAttribution.swift +++ b/Kanban/Git/CommitAttribution.swift @@ -53,20 +53,20 @@ public struct HarvestedReceipt: Sendable, Equatable { public struct CommitSplit: Sendable, Equatable { /// Changes nobody vouched for — an agent, a text editor, a terminal, or a blind window at launch. - public var foreign: [GitChangedPath] = [] + public var foreign: [ChangedPath] = [] /// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made /// them (`EchoLedger.markHeal`). - public var heal: [GitChangedPath] = [] + public var heal: [ChangedPath] = [] /// The user acting through the app. - public var user: [GitChangedPath] = [] + public var user: [ChangedPath] = [] public init() {} /// One class of one window's changes, ready to become a commit. public struct Group: Sendable, Equatable { - public let paths: [GitChangedPath] + public let paths: [ChangedPath] /// Which class it is — carried rather than re-derived, so the planner never has to ask a /// list whether it contains its own members. public let kind: Kind @@ -171,7 +171,7 @@ public enum CommitAttribution { /// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with /// its own content receipt rather than with the move above it. public static func split( - _ paths: [GitChangedPath], + _ paths: [ChangedPath], under boardRoot: URL, receipts: [String: HarvestedReceipt] ) -> CommitSplit { @@ -236,7 +236,7 @@ public enum CommitAttribution { /// does — which is why the agent guide teaches re-stamping on move. /// /// A window of nothing but rename departures leaves no stamp to agree on and falls back too. - public static func foreignIdentity(for paths: [GitChangedPath], under boardRoot: URL) -> GitIdentity { + public static func foreignIdentity(for paths: [ChangedPath], under boardRoot: URL) -> GitIdentity { var stamps: Set = [] for path in paths { if path.isDeletion { diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index fcd7f61..9815e09 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -130,7 +130,7 @@ public final class GitAutoCommitter { /// (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 = SemanticCommitMessage() + public var composer: any ChangeNarrating = SemanticChangeNarration() /// 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. @@ -676,7 +676,7 @@ public final class GitAutoCommitter { let boardRoot: URL let excludedFolders: [String] let receipts: [String: HarvestedReceipt] - let composer: any CommitMessageComposing + let composer: any ChangeNarrating let snapshot: BoardModel? } @@ -740,7 +740,7 @@ public final class GitAutoCommitter { /// 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. + /// to its own commit by `ChangeNarrationRequest.changedPaths`, which the split already narrows. private struct Composition: Sendable { var previous: BoardModel? var current: BoardModel? @@ -749,13 +749,13 @@ public final class GitAutoCommitter { } /// 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. + /// here so `ChangeNarrator` 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], + for changed: [ChangedPath], input: FlushInput ) -> Composition { var composition = Composition() @@ -769,7 +769,7 @@ public final class GitAutoCommitter { // 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 touchesModel = changed.contains { ChangeNarrator.Paths.mightAffectSnapshot($0.path) } let namesACard = changed.contains { CommentPath.classify($0.path) != nil } // **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the // one field of a comment the composer needs and the board snapshot cannot carry. Read beside @@ -791,7 +791,7 @@ public final class GitAutoCommitter { } /// **When each comment this window touched was created**, keyed by its folder — the chronology - /// `CommitMessageEngine` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed + /// `ChangeNarrator` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed /// 2026-07-31: "by the comments' own `created`, folder name on ties"). /// /// One `index.md` per touched comment folder, read off the **working tree** — which is the state @@ -806,13 +806,13 @@ public final class GitAutoCommitter { /// way a flush does, instead of hand-assembling a map the flush could never produce /// (`WriterFixture.snapshot()`'s reason, restated one field down). nonisolated static func commentTimestamps( - for changed: [GitChangedPath], + for changed: [ChangedPath], boardRoot: URL ) -> [String: Date] { var timestamps: [String: Date] = [:] var seen: Set = [] for path in changed { - guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted + guard let folder = ChangeNarrator.commentFolder(of: path.path), seen.insert(folder).inserted else { continue } let index = boardRoot .appendingPathComponent(folder) @@ -828,7 +828,7 @@ public final class GitAutoCommitter { /// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes. private nonisolated static func plan( - _ changed: [GitChangedPath], + _ changed: [ChangedPath], reading: GitRepositoryReading, input: FlushInput, composition: Composition @@ -836,11 +836,11 @@ public final class GitAutoCommitter { let user = GitCommitOperation.userIdentity(at: input.boardRoot) func request( - _ paths: [GitChangedPath], - _ authorship: CommitAuthorship, + _ paths: [ChangedPath], + _ authorship: ChangeAuthorship, isRootCommit: Bool = false - ) -> CommitMessageRequest { - CommitMessageRequest( + ) -> ChangeNarrationRequest { + ChangeNarrationRequest( boardRoot: input.boardRoot, changedPaths: paths, authorship: authorship, @@ -862,7 +862,7 @@ public final class GitAutoCommitter { guard !reading.isUnborn else { return [PlannedCommit( paths: changed.map(\.path), - message: input.composer.message(for: request(changed, .user, isRootCommit: true)), + message: input.composer.narrative(for: request(changed, .user, isRootCommit: true)), author: user, committer: user, kind: .root @@ -871,28 +871,28 @@ public final class GitAutoCommitter { let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts) return split.ordered.map { group in - let authorship: CommitAuthorship + // One combined switch over `group.kind`, producing both `authorship` (what the message + // seam is allowed to know) and `author` (who the commit is actually by) — the foreign + // branch resolves the identity once and both derive from it. + let authorship: ChangeAuthorship + let author: GitIdentity switch group.kind { case .foreign: - authorship = .foreign( - CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot) - ) + let identity = CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot) + authorship = .foreign(identity.name) + author = identity // **A heal is authored `Lanework Integrity `** (06 ▸ Commit // messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third // origin — not the user's gesture, not a foreign writer — and the separation exists for // audit, so the trail filters by author like every origin". This authored heals as the // *user* until that ruling, which left the separate commit filterable only by message // shape — and the shape vocabulary deliberately never says "healed". - case .heal: authorship = .heal - case .user: authorship = .user - } - // The committer stays the user throughout — 06's recorded-by convention, which is why - // only the author varies here. - let author: GitIdentity - switch authorship { - case let .foreign(identity): author = identity - case .heal: author = CommitAttribution.integrityIdentity - case .user: author = user + case .heal: + authorship = .heal + author = CommitAttribution.integrityIdentity + case .user: + authorship = .user + author = user } let kind: PlannedCommitKind switch group.kind { @@ -900,9 +900,11 @@ public final class GitAutoCommitter { case .heal: kind = .heal case .user: kind = .user } + // The committer stays the user throughout — 06's recorded-by convention, which is why + // only the author varies above. return PlannedCommit( paths: group.paths.map(\.path), - message: input.composer.message(for: request(group.paths, authorship)), + message: input.composer.narrative(for: request(group.paths, authorship)), author: author, committer: user, kind: kind diff --git a/Kanban/Git/GitCommitOperation.swift b/Kanban/Git/GitCommitOperation.swift index f45e02d..0ef9106 100644 --- a/Kanban/Git/GitCommitOperation.swift +++ b/Kanban/Git/GitCommitOperation.swift @@ -88,50 +88,6 @@ public struct GitRepositoryReading: Sendable, Equatable { } } -// MARK: - Changed paths - -/// One path `git status` reports as differing between HEAD and the working tree. -/// -/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or -/// removes exactly these, and attribution asks a question per *file* (06 ▸ Interaction with external -/// writers: "classify every observed change, per file"). -public struct GitChangedPath: Sendable, Equatable, Hashable { - - /// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot). - public let path: String - - /// Whether the file is **gone** from the working tree. - /// - /// The `modified-by` rule turns on this bit — "any true deletion in the window falls back to - /// `Lanework External` — a deletion leaves no file to stamp" — which is why the rename half - /// below is a separate fact rather than folded in here. - public let isDeletion: Bool - - /// Whether this path is one end of a **rename** libgit2 paired up. - /// - /// "**A folder move is not a deletion**: items match by id across the whole board … so a moved - /// card attributes by its stamp like any changed file" (06). A paired departure is therefore a - /// deletion on disk that the window must not be demoted by. - public let 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 - } -} - // MARK: - A planned commit /// **Which of 06's classes a planned commit belongs to** — carried through the libgit2 work so a @@ -174,7 +130,7 @@ public struct GitLandedCommit: Sendable, Equatable { /// two-commit split; ruled 2026-07-29: the heal's third class). public struct PlannedCommit: Sendable, Equatable { - /// Board-root-relative paths, exactly as `GitChangedPath.path` spells them. + /// Board-root-relative paths, exactly as `ChangedPath.path` spells them. public let paths: [String] public let message: String @@ -380,7 +336,7 @@ enum GitCommitOperation { /// but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing." /// (Found by test rather than by reading: the failure suite went green-by-silence when discovery /// moved from `git_status` to staging.) - nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? { + nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [ChangedPath]? { _ = startUp guard let repository = open(boardRoot) else { return nil } defer { git_repository_free(repository) } @@ -393,11 +349,11 @@ enum GitCommitOperation { /// The survey, with "could not look" folded into "nothing to do" — for the callers that have no /// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests' /// clean-tree assertions. - nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] { + nonisolated static func changedPaths(at boardRoot: URL) -> [ChangedPath] { surveyChangedPaths(at: boardRoot) ?? [] } - private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? { + private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [ChangedPath]? { var pathspec = git_strarray() guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else { return nil @@ -426,12 +382,12 @@ enum GitCommitOperation { _ = git_diff_find_similar(diff, &findOptions) } - var found: [String: GitChangedPath] = [:] + var found: [String: ChangedPath] = [:] 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( + found[path] = ChangedPath( path: path, // 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. diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index c6b772f..960204a 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -619,14 +619,14 @@ struct AutoCommitStageAroundTests { /// A composer that takes its time, so a test can hold a flush open and drive the close sequence into /// the gap. Everything else about it is the real one — this suite asserts *when* a commit exists, and /// a fake message would make the commits it reads back unrecognisable. -private struct SlowComposer: CommitMessageComposing { +private struct SlowComposer: ChangeNarrating { let delay: TimeInterval - func message(for request: CommitMessageRequest) -> String { + func narrative(for request: ChangeNarrationRequest) -> String { // Blocking, deliberately: this runs on the flush's own detached task, and what the test needs // held open is that task rather than the actor the close sequence is running on. Thread.sleep(forTimeInterval: delay) - return CommitMessageEngine.message(for: request) + return ChangeNarrator.narrative(for: request) } } @@ -1336,7 +1336,7 @@ struct AutoCommitMessageTests { /// its subject describes a board that has not heard about the card it is committing. @Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned") func aStaleSnapshotComposesTheShrug() async throws { - #expect(try await flushRacingItsReload(awaitsCoverage: false) == CommitMessageEngine.unnamedSubject) + #expect(try await flushRacingItsReload(awaitsCoverage: false) == ChangeNarrator.unnamedSubject) } /// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and @@ -1345,7 +1345,7 @@ struct AutoCommitMessageTests { func theWaitIsBounded() async throws { // The generation never moves, so the wait runs to its (millisecond) deadline and composes. #expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max) - == CommitMessageEngine.unnamedSubject) + == ChangeNarrator.unnamedSubject) } } @@ -1380,8 +1380,8 @@ struct CommitAttributionRuleTests { try fixture.item("lane/card", stamped("Moved", by: "claude")) let paths = [ - GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true), - GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true) + ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true), + ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true) ] // "**A folder move is not a deletion**: items match by id across the whole board." #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root).name == "claude") @@ -1391,7 +1391,7 @@ struct CommitAttributionRuleTests { func departuresAloneFallBack() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } - let paths = [GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)] + let paths = [ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)] #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root) == CommitAttribution.externalIdentity) } @@ -1417,7 +1417,7 @@ struct CommitAttributionRuleTests { let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md")) let matching = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: false)] let stale = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: "other")), isHeal: false)] - let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] + let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] #expect(CommitAttribution.split(changed, under: fixture.root, receipts: matching).user == changed) // "a foreign edit landing on an app-written path inside the same window misses the hash and @@ -1436,7 +1436,7 @@ struct CommitAttributionRuleTests { let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md")) let receipts = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: true)] - let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] + let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] let split = CommitAttribution.split(changed, under: fixture.root, receipts: receipts) #expect(split.heal == changed) diff --git a/KanbanTests/CommitMessageTests.swift b/KanbanTests/ChangeNarratorTests.swift similarity index 95% rename from KanbanTests/CommitMessageTests.swift rename to KanbanTests/ChangeNarratorTests.swift index 94f205e..1438c43 100644 --- a/KanbanTests/CommitMessageTests.swift +++ b/KanbanTests/ChangeNarratorTests.swift @@ -2,11 +2,11 @@ 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. +/// **Change narrator — the semantic message spec** (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 +/// **No repository anywhere in this file.** The narrator 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 @@ -48,13 +48,13 @@ private func files(under root: URL) -> [String: Data] { /// 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] { +private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] { let old = files(under: before) let new = files(under: after) - var paths: [GitChangedPath] = [] + var paths: [ChangedPath] = [] 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( + paths.append(ChangedPath( path: path, isDeletion: false, isRename: false, @@ -62,7 +62,7 @@ private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] { )) } for path in old.keys where new[path] == nil { - paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false)) + paths.append(ChangedPath(path: path, isDeletion: true, isRename: false)) } return paths.sorted { $0.path < $1.path } } @@ -79,7 +79,7 @@ private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] { private func compose( board: (WriterFixture) throws -> Void = baseBoard, change: (WriterFixture) throws -> Void, - authorship: CommitAuthorship = .user, + authorship: ChangeAuthorship = .user, renames: Set = [], guideText: String? = nil ) throws -> String { @@ -94,7 +94,7 @@ private func compose( let paths = changedPaths(from: before.root, to: after.root).map { path in renames.contains(path.path) - ? GitChangedPath( + ? ChangedPath( path: path.path, isDeletion: path.isDeletion, isRename: true, @@ -102,7 +102,7 @@ private func compose( ) : path } - return CommitMessageEngine.message(for: CommitMessageRequest( + return ChangeNarrator.narrative(for: ChangeNarrationRequest( boardRoot: after.root, changedPaths: paths, authorship: authorship, @@ -129,7 +129,7 @@ private func body(of message: String) -> [String] { // MARK: - One event, one subject -@Suite("Commit messages ▸ a single event is the subject") +@Suite("Change narrator ▸ a single event is the subject") struct CommitMessageSingleEventTests { @Test("Adding a card names the card, with its destination as the detail") @@ -262,7 +262,7 @@ struct CommitMessageSingleEventTests { // MARK: - The trash pair -@Suite("Commit messages ▸ the trash pair, by diff shape alone") +@Suite("Change narrator ▸ the trash pair, by diff shape alone") struct CommitMessageTrashTests { @Test("A move into .trash/ is Delete") @@ -337,7 +337,7 @@ struct CommitMessageTrashTests { // MARK: - Folding -@Suite("Commit messages ▸ folding") +@Suite("Change narrator ▸ folding") struct CommitMessageFoldingTests { @Test("Several of one kind fold, and a shared destination survives into the subject") @@ -395,7 +395,7 @@ struct CommitMessageFoldingTests { // MARK: - The external gap -@Suite("Commit messages ▸ the external gap, closed") +@Suite("Change narrator ▸ the external gap, closed") struct CommitMessageExternalSurfaceTests { @Test("Labels, assignees and due dates each compose a named subject") @@ -469,13 +469,13 @@ struct CommitMessageExternalSurfaceTests { 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 { + func move(_ authorship: ChangeAuthorship) 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 foreign = try move(.foreign("Lanework External")) let heal = try move(.heal) #expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing") #expect(foreign == app) @@ -485,7 +485,7 @@ struct CommitMessageExternalSurfaceTests { // MARK: - Bookkeeping -@Suite("Commit messages ▸ bookkeeping composes nothing") +@Suite("Change narrator ▸ bookkeeping composes nothing") struct CommitMessageBookkeepingTests { @Test("A bumped modified stamp is not an event") @@ -496,7 +496,7 @@ struct CommitMessageBookkeepingTests { modified: "2026-07-31T12:00:00Z" ) } - #expect(message == CommitMessageEngine.unnamedSubject) + #expect(message == ChangeNarrator.unnamedSubject) } @Test("A renumber's rescale preserves sequence and composes nothing") @@ -507,7 +507,7 @@ struct CommitMessageBookkeepingTests { 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.unnamedSubject) + #expect(message == ChangeNarrator.unnamedSubject) } @Test("An on-touch heal's backfilled kind is not an event") @@ -518,7 +518,7 @@ struct CommitMessageBookkeepingTests { "---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n" ) } - #expect(message == CommitMessageEngine.unnamedSubject) + #expect(message == ChangeNarrator.unnamedSubject) } @Test("A renumber batches with the insert that triggered it") @@ -537,7 +537,7 @@ struct CommitMessageBookkeepingTests { // MARK: - Titles -@Suite("Commit messages ▸ titles") +@Suite("Change narrator ▸ titles") struct CommitMessageTitleTests { @Test("Titles truncate in subjects only; bodies carry them whole") @@ -568,7 +568,7 @@ struct CommitMessageTitleTests { // MARK: - Non-snapshot paths -@Suite("Commit messages ▸ non-snapshot files") +@Suite("Change narrator ▸ non-snapshot files") struct CommitMessagePathEventTests { @Test("A stray composes its own path-shaped event, and several fold") @@ -637,7 +637,7 @@ struct CommitMessagePathEventTests { let staged = changedPaths(from: before.root, to: after.root) .filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") } - let message = CommitMessageEngine.message(for: CommitMessageRequest( + let message = ChangeNarrator.narrative(for: ChangeNarrationRequest( boardRoot: after.root, changedPaths: staged, authorship: .user, @@ -651,7 +651,7 @@ struct CommitMessagePathEventTests { // MARK: - The comment verb family -@Suite("Commit messages ▸ the comment verb family") +@Suite("Change narrator ▸ the comment verb family") struct CommitMessageCommentTests { private static let commentA = "cccccccc-0000-4000-8000-000000000001" @@ -798,7 +798,7 @@ struct CommitMessageCommentTests { let message = try compose { fixture in try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note.")) } - #expect(message != CommitMessageEngine.unnamedSubject) + #expect(message != ChangeNarrator.unnamedSubject) #expect(!message.contains("comments/")) } @@ -926,7 +926,7 @@ struct CommitMessageCommentTests { // MARK: - Repair -@Suite("Commit messages ▸ Repair") +@Suite("Change narrator ▸ Repair") struct CommitMessageRepairTests { /// The remint's exact shape, assembled rather than provoked. @@ -937,7 +937,7 @@ struct CommitMessageRepairTests { /// 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 { + private func remintMessage(authorship: ChangeAuthorship) throws -> String { let before = try WriterFixture() defer { before.tearDown() } let after = try WriterFixture() @@ -950,11 +950,11 @@ struct CommitMessageRepairTests { 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) + ? ChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true) : path - } + [GitChangedPath(path: departed, isDeletion: true, isRename: true)] + } + [ChangedPath(path: departed, isDeletion: true, isRename: true)] - return CommitMessageEngine.message(for: CommitMessageRequest( + return ChangeNarrator.narrative(for: ChangeNarrationRequest( boardRoot: after.root, changedPaths: paths, authorship: authorship, @@ -977,7 +977,7 @@ struct CommitMessageRepairTests { // MARK: - The root commit -@Suite("Commit messages ▸ the root commit") +@Suite("Change narrator ▸ the root commit") struct CommitMessageRootCommitTests { @Test("The repository's first commit has the one fixed subject") @@ -985,7 +985,7 @@ struct CommitMessageRootCommitTests { let fixture = try WriterFixture() defer { fixture.tearDown() } try baseBoard(fixture) - let message = CommitMessageEngine.message(for: CommitMessageRequest( + let message = ChangeNarrator.narrative(for: ChangeNarrationRequest( boardRoot: fixture.root, changedPaths: changedPaths(from: fixture.root, to: fixture.root), authorship: .user,