The message engine outlives its substrate — harvested to Kanban/Changes/ as the change narrator
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>, departures: Set<String>)
|
||||
) -> [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<String>, departures: Set<String>) {
|
||||
var arrivals: Set<String> = []
|
||||
var departures: Set<String> = []
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
for path in paths {
|
||||
if path.isDeletion {
|
||||
|
||||
@@ -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<String> = []
|
||||
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 <integrity@lanework.invalid>`** (06 ▸ Commit
|
||||
// messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
|
||||
// origin — not the user's gesture, not a foreign writer — and the separation exists for
|
||||
// audit, so the trail filters by author like every origin". This authored heals as the
|
||||
// *user* until that ruling, which left the separate commit filterable only by message
|
||||
// shape — and the shape vocabulary deliberately never says "healed".
|
||||
case .heal: authorship = .heal
|
||||
case .user: authorship = .user
|
||||
}
|
||||
// The committer stays the user throughout — 06's recorded-by convention, which is why
|
||||
// only the author varies here.
|
||||
let author: GitIdentity
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user