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
|
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
|
// MARK: - Authorship
|
||||||
|
|
||||||
/// Which of the three classes a commit is — the axis the message engine is allowed to know about.
|
/// 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
|
/// 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
|
/// it never has to be plumbed later, and having it *named* means the rule about not using it has
|
||||||
/// something to point at.
|
/// something to point at.
|
||||||
public enum CommitAuthorship: Sendable, Equatable {
|
public enum ChangeAuthorship: Sendable, Equatable {
|
||||||
/// The user acting through the app.
|
/// The user acting through the app.
|
||||||
case user
|
case user
|
||||||
/// A scheduled heal's own commit (ruled 2026-07-29).
|
/// A scheduled heal's own commit (ruled 2026-07-29).
|
||||||
case heal
|
case heal
|
||||||
/// Everything else, carrying the author it will be committed under.
|
/// Everything else, carrying the display name it will be recorded under.
|
||||||
case foreign(GitIdentity)
|
case foreign(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The request
|
// 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
|
/// type rather than every call site. It did need two — `previousSnapshot` and `agentGuideText`, both
|
||||||
/// below — and that is exactly what this shape was for.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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.
|
/// The board this is a commit in.
|
||||||
public let boardRoot: URL
|
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
|
/// **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
|
/// snapshot diff it receives the changed-path list, and non-snapshot paths compose *path-shaped
|
||||||
/// events*"), narrowed to the paths *this* commit stages.
|
/// events*"), narrowed to the paths *this* commit stages.
|
||||||
public let changedPaths: [GitChangedPath]
|
public let changedPaths: [ChangedPath]
|
||||||
|
|
||||||
/// Which class this commit is.
|
/// 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
|
/// 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).
|
/// ("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.
|
/// is a commit that will have to be described by its paths.
|
||||||
public let snapshot: BoardModel?
|
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
|
/// `nil` when the caller has nothing to diff against — the git stack's case was an unborn HEAD
|
||||||
/// tree does not load as a board. It is read from the repository rather than carried forward from
|
/// (where `isRootCommit` already says everything) or a HEAD whose tree did not load as a board. The
|
||||||
/// the last commit the app made, because the app is not the only writer and because launch
|
/// git stack read it from the repository rather than carrying it forward from the last commit the
|
||||||
/// catch-up has no carried value to offer: see `GitHeadSnapshot` for the whole of that argument.
|
/// 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?
|
public let previousSnapshot: BoardModel?
|
||||||
|
|
||||||
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it — the one
|
/// **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?
|
public let agentGuideText: String?
|
||||||
|
|
||||||
/// **When each of this commit's comments was created** — keyed by the comment folder's
|
/// **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
|
/// 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
|
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically — by the
|
||||||
@@ -94,8 +103,8 @@ public struct CommitMessageRequest: Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
boardRoot: URL,
|
boardRoot: URL,
|
||||||
changedPaths: [GitChangedPath],
|
changedPaths: [ChangedPath],
|
||||||
authorship: CommitAuthorship,
|
authorship: ChangeAuthorship,
|
||||||
isRootCommit: Bool,
|
isRootCommit: Bool,
|
||||||
snapshot: BoardModel?,
|
snapshot: BoardModel?,
|
||||||
previousSnapshot: BoardModel? = nil,
|
previousSnapshot: BoardModel? = nil,
|
||||||
@@ -117,7 +126,7 @@ public struct CommitMessageRequest: Sendable {
|
|||||||
|
|
||||||
/// **What a commit says** (06-history-undo.md ▸ Commit messages).
|
/// **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
|
/// 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,
|
/// 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
|
/// 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
|
/// `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
|
/// and commits — the message has to be in hand before `git_commit_create` is called, and none of the
|
||||||
/// work is main-actor work.
|
/// work is main-actor work.
|
||||||
public protocol CommitMessageComposing: Sendable {
|
public protocol ChangeNarrating: Sendable {
|
||||||
func message(for request: CommitMessageRequest) -> String
|
func narrative(for request: ChangeNarrationRequest) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The wired composer
|
// MARK: - The wired composer
|
||||||
|
|
||||||
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.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
|
/// 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
|
/// the seam takes an existential, and because a *named* default is what makes "the engine's composer
|
||||||
/// is the semantic one" assertable.
|
/// is the semantic one" assertable.
|
||||||
public struct SemanticCommitMessage: CommitMessageComposing {
|
public struct SemanticChangeNarration: ChangeNarrating {
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func message(for request: CommitMessageRequest) -> String {
|
public func narrative(for request: ChangeNarrationRequest) -> String {
|
||||||
CommitMessageEngine.message(for: request)
|
ChangeNarrator.narrative(for: request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
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
|
/// **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.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// subject, and the heal window's Repair reading of a folder remint.
|
||||||
///
|
///
|
||||||
/// ### Purity, and where the impurity went
|
/// ### 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
|
/// 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 —
|
/// 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
|
/// every subject form, every fold, every trash reading — testable with two snapshots and no
|
||||||
/// repository at all.
|
/// 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
|
/// 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
|
/// composes nothing, a `.gitignore`d attachment never composes a phantom Attach, and a stray-only
|
||||||
/// window still commits with its strays named.
|
/// 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
|
/// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring
|
||||||
/// the bare 'Update board' fallback"):
|
/// the bare 'Update board' fallback"):
|
||||||
@@ -91,7 +96,7 @@ enum CommitMessageEngine {
|
|||||||
// MARK: - Entry point
|
// MARK: - Entry point
|
||||||
|
|
||||||
/// One request in, one whole message out — a subject, and a body when there is more to say.
|
/// 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
|
// **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
|
// commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
|
||||||
// no last-committed snapshot to diff against."
|
// 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
|
/// 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
|
/// worded*: the two halves fail differently, and a diff bug should not have to be read out of a
|
||||||
/// phrasing assertion.
|
/// phrasing assertion.
|
||||||
static func events(for request: CommitMessageRequest) -> [Event] {
|
static func events(for request: ChangeNarrationRequest) -> [Event] {
|
||||||
let changed = Set(request.changedPaths.map(\.path))
|
let changed = Set(request.changedPaths.map(\.path))
|
||||||
var model: [Event] = []
|
var model: [Event] = []
|
||||||
if let previous = request.previousSnapshot, let current = request.snapshot {
|
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.
|
/// - 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 —
|
/// - 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".
|
/// "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 }
|
guard !events.isEmpty else { return unnamedSubject }
|
||||||
if events.count == 1 {
|
if events.count == 1 {
|
||||||
guard let detail = events[0].detail else { return events[0].subject }
|
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
|
/// 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
|
/// guide — is by definition not one card's window, so a single `nil` answer decides the whole
|
||||||
/// question.
|
/// question.
|
||||||
private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? {
|
private static func sharedItem(of events: [Event], request: ChangeNarrationRequest) -> String? {
|
||||||
var folder: String?
|
var folder: String?
|
||||||
for event in events {
|
for event in events {
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
@@ -200,7 +205,7 @@ enum CommitMessageEngine {
|
|||||||
private static func modelEvents(
|
private static func modelEvents(
|
||||||
from previous: BoardModel,
|
from previous: BoardModel,
|
||||||
to current: BoardModel,
|
to current: BoardModel,
|
||||||
request: CommitMessageRequest
|
request: ChangeNarrationRequest
|
||||||
) -> [Event] {
|
) -> [Event] {
|
||||||
let previousLanes = laneIndex(of: previous)
|
let previousLanes = laneIndex(of: previous)
|
||||||
let currentLanes = laneIndex(of: current)
|
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).
|
/// (03-board-ui.md § Trash: the row draws a title and a count and takes no styling accents).
|
||||||
let live: Lane?
|
let live: Lane?
|
||||||
|
|
||||||
var displayTitle: String { CommitMessageEngine.title(title) }
|
var displayTitle: String { ChangeNarrator.title(title) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func laneIndex(of board: BoardModel) -> [ItemID: LaneEntry] {
|
private static func laneIndex(of board: BoardModel) -> [ItemID: LaneEntry] {
|
||||||
@@ -429,7 +434,7 @@ enum CommitMessageEngine {
|
|||||||
let path: String
|
let path: String
|
||||||
let attachmentFolder: 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] {
|
private static func cardIndex(of board: BoardModel) -> [ItemID: CardEntry] {
|
||||||
@@ -463,7 +468,7 @@ enum CommitMessageEngine {
|
|||||||
previousLanes: [ItemID: LaneEntry],
|
previousLanes: [ItemID: LaneEntry],
|
||||||
currentLanes: [ItemID: LaneEntry],
|
currentLanes: [ItemID: LaneEntry],
|
||||||
laneOutcomes: [Event],
|
laneOutcomes: [Event],
|
||||||
authorship: CommitAuthorship,
|
authorship: ChangeAuthorship,
|
||||||
renames: (arrivals: Set<String>, departures: Set<String>)
|
renames: (arrivals: Set<String>, departures: Set<String>)
|
||||||
) -> [Event] {
|
) -> [Event] {
|
||||||
var events: [Event] = []
|
var events: [Event] = []
|
||||||
@@ -791,7 +796,7 @@ enum CommitMessageEngine {
|
|||||||
/// content replacement, named from the path alone: 'Replace attachment 'photo.png' — card 'X'',
|
/// 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
|
/// 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`.
|
/// 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))
|
let claimed = Set(model.flatMap(\.paths))
|
||||||
var comments: [String: CommentGroup] = [:]
|
var comments: [String: CommentGroup] = [:]
|
||||||
var events: [Event] = []
|
var events: [Event] = []
|
||||||
@@ -873,7 +878,7 @@ enum CommitMessageEngine {
|
|||||||
let comment: CommentPath
|
let comment: CommentPath
|
||||||
|
|
||||||
/// This comment's own folder, board-root-relative — the key its `created` is looked up under
|
/// 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
|
let folder: String
|
||||||
|
|
||||||
var paths: [String] = []
|
var paths: [String] = []
|
||||||
@@ -893,7 +898,7 @@ enum CommitMessageEngine {
|
|||||||
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
|
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func add(_ changed: GitChangedPath) {
|
mutating func add(_ changed: ChangedPath) {
|
||||||
paths.append(changed.path)
|
paths.append(changed.path)
|
||||||
if changed.isArrival { hasArrival = true }
|
if changed.isArrival { hasArrival = true }
|
||||||
if !changed.isDeletion { hasSurvivor = 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
|
/// "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
|
/// 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
|
/// 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
|
/// 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
|
/// arrives as a plain addition, and both read identically, which is the origin-agnostic rule one
|
||||||
/// level down.
|
/// level down.
|
||||||
@@ -947,7 +952,7 @@ enum CommitMessageEngine {
|
|||||||
private static func commentEvents(
|
private static func commentEvents(
|
||||||
_ groups: [String: CommentGroup],
|
_ groups: [String: CommentGroup],
|
||||||
model: [Event],
|
model: [Event],
|
||||||
request: CommitMessageRequest
|
request: ChangeNarrationRequest
|
||||||
) -> [Event] {
|
) -> [Event] {
|
||||||
guard !groups.isEmpty else { return [] }
|
guard !groups.isEmpty else { return [] }
|
||||||
let titles = cardTitlesByPath(request)
|
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
|
/// 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.
|
/// 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] = [:]
|
var titles: [String: String] = [:]
|
||||||
for board in [request.previousSnapshot, request.snapshot].compactMap({ $0 }) {
|
for board in [request.previousSnapshot, request.snapshot].compactMap({ $0 }) {
|
||||||
for lane in board.lanes {
|
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
|
/// of them agrees on it ("moved 3 cards, all to Done" is worth saying in a subject; "to 3
|
||||||
/// different places" isn't).
|
/// different places" isn't).
|
||||||
func plural(_ count: Int, destination: String?) -> String {
|
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 {
|
switch self {
|
||||||
case .addCard: return "Add \(count) cards\(target)"
|
case .addCard: return "Add \(count) cards\(target)"
|
||||||
case .deleteCard: return "Delete \(count) cards"
|
case .deleteCard: return "Delete \(count) cards"
|
||||||
@@ -1167,19 +1172,19 @@ enum CommitMessageEngine {
|
|||||||
case .dueCard: return "Set due date on \(count) cards"
|
case .dueCard: return "Set due date on \(count) cards"
|
||||||
case .changeKeyCard:
|
case .changeKeyCard:
|
||||||
guard let destination else { return "Change custom keys on \(count) cards" }
|
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:
|
case .attachFile:
|
||||||
guard let destination else { return "Attach \(count) files" }
|
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:
|
case .removeFile:
|
||||||
guard let destination else { return "Remove \(count) files" }
|
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:
|
case .replaceFile:
|
||||||
guard let destination else { return "Replace \(count) attachments" }
|
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:
|
case .reorderCards:
|
||||||
guard let destination else { return "Reorder cards in \(count) lanes" }
|
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 .repairDuplicate: return "Repair \(count) duplicates"
|
||||||
|
|
||||||
case .addLane: return "Add \(count) lanes"
|
case .addLane: return "Add \(count) lanes"
|
||||||
@@ -1195,7 +1200,7 @@ enum CommitMessageEngine {
|
|||||||
case .dueLane: return "Set due date on \(count) lanes"
|
case .dueLane: return "Set due date on \(count) lanes"
|
||||||
case .changeKeyLane:
|
case .changeKeyLane:
|
||||||
guard let destination else { return "Change custom keys on \(count) lanes" }
|
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
|
// 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.
|
// 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.
|
// event — unreachable in practice, and count-less if it ever is.
|
||||||
case .changeKeyBoard:
|
case .changeKeyBoard:
|
||||||
guard let destination else { return "Change custom keys on board" }
|
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 .agentGuide: return "Update agent guide"
|
||||||
case .updatePath: return "Update \(count) files"
|
case .updatePath: return "Update \(count) files"
|
||||||
@@ -1221,16 +1226,16 @@ enum CommitMessageEngine {
|
|||||||
// count: "3 comments on 'X'".
|
// count: "3 comments on 'X'".
|
||||||
case .commentPosted:
|
case .commentPosted:
|
||||||
guard let destination else { return "Comment on \(count) cards" }
|
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:
|
case .commentEdited:
|
||||||
guard let destination else { return "Edit \(count) comments" }
|
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:
|
case .commentDeleted:
|
||||||
guard let destination else { return "Delete \(count) comments" }
|
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:
|
case .commentPurged:
|
||||||
guard let destination else { return "Permanently delete \(count) comments" }
|
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.
|
// One draft per card, so several can only mean several cards.
|
||||||
case .commentDrafted: return "Draft comment on \(count) 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.
|
/// Both ends of every rename libgit2 paired up in this commit, split by direction.
|
||||||
private static func renamedPaths(
|
private static func renamedPaths(
|
||||||
in request: CommitMessageRequest
|
in request: ChangeNarrationRequest
|
||||||
) -> (arrivals: Set<String>, departures: Set<String>) {
|
) -> (arrivals: Set<String>, departures: Set<String>) {
|
||||||
var arrivals: Set<String> = []
|
var arrivals: Set<String> = []
|
||||||
var departures: 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 {
|
public struct CommitSplit: Sendable, Equatable {
|
||||||
|
|
||||||
/// Changes nobody vouched for — an agent, a text editor, a terminal, or a blind window at launch.
|
/// 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
|
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
|
||||||
/// them (`EchoLedger.markHeal`).
|
/// them (`EchoLedger.markHeal`).
|
||||||
public var heal: [GitChangedPath] = []
|
public var heal: [ChangedPath] = []
|
||||||
|
|
||||||
/// The user acting through the app.
|
/// The user acting through the app.
|
||||||
public var user: [GitChangedPath] = []
|
public var user: [ChangedPath] = []
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
/// One class of one window's changes, ready to become a commit.
|
/// One class of one window's changes, ready to become a commit.
|
||||||
public struct Group: Sendable, Equatable {
|
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
|
/// Which class it is — carried rather than re-derived, so the planner never has to ask a
|
||||||
/// list whether it contains its own members.
|
/// list whether it contains its own members.
|
||||||
public let kind: Kind
|
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
|
/// 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.
|
/// its own content receipt rather than with the move above it.
|
||||||
public static func split(
|
public static func split(
|
||||||
_ paths: [GitChangedPath],
|
_ paths: [ChangedPath],
|
||||||
under boardRoot: URL,
|
under boardRoot: URL,
|
||||||
receipts: [String: HarvestedReceipt]
|
receipts: [String: HarvestedReceipt]
|
||||||
) -> CommitSplit {
|
) -> CommitSplit {
|
||||||
@@ -236,7 +236,7 @@ public enum CommitAttribution {
|
|||||||
/// does — which is why the agent guide teaches re-stamping on move.
|
/// 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.
|
/// 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> = []
|
var stamps: Set<String> = []
|
||||||
for path in paths {
|
for path in paths {
|
||||||
if path.isDeletion {
|
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
|
/// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was
|
||||||
/// asked for without asserting what it said.
|
/// asked for without asserting what it said.
|
||||||
@ObservationIgnored
|
@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
|
/// 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.
|
/// attached, which is every storeless test.
|
||||||
@@ -676,7 +676,7 @@ public final class GitAutoCommitter {
|
|||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
let excludedFolders: [String]
|
let excludedFolders: [String]
|
||||||
let receipts: [String: HarvestedReceipt]
|
let receipts: [String: HarvestedReceipt]
|
||||||
let composer: any CommitMessageComposing
|
let composer: any ChangeNarrating
|
||||||
let snapshot: BoardModel?
|
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
|
/// 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
|
/// (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
|
/// 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 {
|
private struct Composition: Sendable {
|
||||||
var previous: BoardModel?
|
var previous: BoardModel?
|
||||||
var current: 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
|
/// 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
|
/// **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
|
/// 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.
|
/// twice to describe a changed `.gitignore` would be work with no reader.
|
||||||
private nonisolated static func composition(
|
private nonisolated static func composition(
|
||||||
for changed: [GitChangedPath],
|
for changed: [ChangedPath],
|
||||||
input: FlushInput
|
input: FlushInput
|
||||||
) -> Composition {
|
) -> Composition {
|
||||||
var composition = 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
|
// 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
|
// 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.
|
// 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 }
|
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
|
||||||
// **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the
|
// **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
|
// 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
|
/// **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").
|
/// 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
|
/// 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
|
/// way a flush does, instead of hand-assembling a map the flush could never produce
|
||||||
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
|
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
|
||||||
nonisolated static func commentTimestamps(
|
nonisolated static func commentTimestamps(
|
||||||
for changed: [GitChangedPath],
|
for changed: [ChangedPath],
|
||||||
boardRoot: URL
|
boardRoot: URL
|
||||||
) -> [String: Date] {
|
) -> [String: Date] {
|
||||||
var timestamps: [String: Date] = [:]
|
var timestamps: [String: Date] = [:]
|
||||||
var seen: Set<String> = []
|
var seen: Set<String> = []
|
||||||
for path in changed {
|
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 }
|
else { continue }
|
||||||
let index = boardRoot
|
let index = boardRoot
|
||||||
.appendingPathComponent(folder)
|
.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.
|
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
||||||
private nonisolated static func plan(
|
private nonisolated static func plan(
|
||||||
_ changed: [GitChangedPath],
|
_ changed: [ChangedPath],
|
||||||
reading: GitRepositoryReading,
|
reading: GitRepositoryReading,
|
||||||
input: FlushInput,
|
input: FlushInput,
|
||||||
composition: Composition
|
composition: Composition
|
||||||
@@ -836,11 +836,11 @@ public final class GitAutoCommitter {
|
|||||||
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
||||||
|
|
||||||
func request(
|
func request(
|
||||||
_ paths: [GitChangedPath],
|
_ paths: [ChangedPath],
|
||||||
_ authorship: CommitAuthorship,
|
_ authorship: ChangeAuthorship,
|
||||||
isRootCommit: Bool = false
|
isRootCommit: Bool = false
|
||||||
) -> CommitMessageRequest {
|
) -> ChangeNarrationRequest {
|
||||||
CommitMessageRequest(
|
ChangeNarrationRequest(
|
||||||
boardRoot: input.boardRoot,
|
boardRoot: input.boardRoot,
|
||||||
changedPaths: paths,
|
changedPaths: paths,
|
||||||
authorship: authorship,
|
authorship: authorship,
|
||||||
@@ -862,7 +862,7 @@ public final class GitAutoCommitter {
|
|||||||
guard !reading.isUnborn else {
|
guard !reading.isUnborn else {
|
||||||
return [PlannedCommit(
|
return [PlannedCommit(
|
||||||
paths: changed.map(\.path),
|
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,
|
author: user,
|
||||||
committer: user,
|
committer: user,
|
||||||
kind: .root
|
kind: .root
|
||||||
@@ -871,28 +871,28 @@ public final class GitAutoCommitter {
|
|||||||
|
|
||||||
let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts)
|
let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts)
|
||||||
return split.ordered.map { group in
|
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 {
|
switch group.kind {
|
||||||
case .foreign:
|
case .foreign:
|
||||||
authorship = .foreign(
|
let identity = CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
|
||||||
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
|
// **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
|
// 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
|
// 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
|
// 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
|
// *user* until that ruling, which left the separate commit filterable only by message
|
||||||
// shape — and the shape vocabulary deliberately never says "healed".
|
// shape — and the shape vocabulary deliberately never says "healed".
|
||||||
case .heal: authorship = .heal
|
case .heal:
|
||||||
case .user: authorship = .user
|
authorship = .heal
|
||||||
}
|
author = CommitAttribution.integrityIdentity
|
||||||
// The committer stays the user throughout — 06's recorded-by convention, which is why
|
case .user:
|
||||||
// only the author varies here.
|
authorship = .user
|
||||||
let author: GitIdentity
|
author = user
|
||||||
switch authorship {
|
|
||||||
case let .foreign(identity): author = identity
|
|
||||||
case .heal: author = CommitAttribution.integrityIdentity
|
|
||||||
case .user: author = user
|
|
||||||
}
|
}
|
||||||
let kind: PlannedCommitKind
|
let kind: PlannedCommitKind
|
||||||
switch group.kind {
|
switch group.kind {
|
||||||
@@ -900,9 +900,11 @@ public final class GitAutoCommitter {
|
|||||||
case .heal: kind = .heal
|
case .heal: kind = .heal
|
||||||
case .user: kind = .user
|
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(
|
return PlannedCommit(
|
||||||
paths: group.paths.map(\.path),
|
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,
|
author: author,
|
||||||
committer: user,
|
committer: user,
|
||||||
kind: kind
|
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
|
// MARK: - A planned commit
|
||||||
|
|
||||||
/// **Which of 06's classes a planned commit belongs to** — carried through the libgit2 work so a
|
/// **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).
|
/// two-commit split; ruled 2026-07-29: the heal's third class).
|
||||||
public struct PlannedCommit: Sendable, Equatable {
|
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 paths: [String]
|
||||||
|
|
||||||
public let message: String
|
public let message: String
|
||||||
@@ -380,7 +336,7 @@ enum GitCommitOperation {
|
|||||||
/// but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing."
|
/// 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
|
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
|
||||||
/// moved from `git_status` to staging.)
|
/// moved from `git_status` to staging.)
|
||||||
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
|
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [ChangedPath]? {
|
||||||
_ = startUp
|
_ = startUp
|
||||||
guard let repository = open(boardRoot) else { return nil }
|
guard let repository = open(boardRoot) else { return nil }
|
||||||
defer { git_repository_free(repository) }
|
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
|
/// 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'
|
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
|
||||||
/// clean-tree assertions.
|
/// clean-tree assertions.
|
||||||
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
|
nonisolated static func changedPaths(at boardRoot: URL) -> [ChangedPath] {
|
||||||
surveyChangedPaths(at: boardRoot) ?? []
|
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()
|
var pathspec = git_strarray()
|
||||||
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
|
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
|
||||||
return nil
|
return nil
|
||||||
@@ -426,12 +382,12 @@ enum GitCommitOperation {
|
|||||||
_ = git_diff_find_similar(diff, &findOptions)
|
_ = 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) {
|
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
|
||||||
guard let path, !path.isEmpty else { return }
|
guard let path, !path.isEmpty else { return }
|
||||||
let existing = found[path]
|
let existing = found[path]
|
||||||
found[path] = GitChangedPath(
|
found[path] = ChangedPath(
|
||||||
path: path,
|
path: path,
|
||||||
// Present wins where two deltas disagree: staging asks "is it there now", and the
|
// 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.
|
// `modified-by` demotion must not fire for a file the window ends with.
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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.
|
/// a fake message would make the commits it reads back unrecognisable.
|
||||||
private struct SlowComposer: CommitMessageComposing {
|
private struct SlowComposer: ChangeNarrating {
|
||||||
let delay: TimeInterval
|
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
|
// 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.
|
// held open is that task rather than the actor the close sequence is running on.
|
||||||
Thread.sleep(forTimeInterval: delay)
|
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.
|
/// 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")
|
@Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned")
|
||||||
func aStaleSnapshotComposesTheShrug() async throws {
|
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
|
/// 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 {
|
func theWaitIsBounded() async throws {
|
||||||
// The generation never moves, so the wait runs to its (millisecond) deadline and composes.
|
// The generation never moves, so the wait runs to its (millisecond) deadline and composes.
|
||||||
#expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max)
|
#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"))
|
try fixture.item("lane/card", stamped("Moved", by: "claude"))
|
||||||
|
|
||||||
let paths = [
|
let paths = [
|
||||||
GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true),
|
ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true),
|
||||||
GitChangedPath(path: "lane/card/index.md", isDeletion: false, 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."
|
// "**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")
|
#expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root).name == "claude")
|
||||||
@@ -1391,7 +1391,7 @@ struct CommitAttributionRuleTests {
|
|||||||
func departuresAloneFallBack() throws {
|
func departuresAloneFallBack() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
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)
|
#expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root)
|
||||||
== CommitAttribution.externalIdentity)
|
== CommitAttribution.externalIdentity)
|
||||||
}
|
}
|
||||||
@@ -1417,7 +1417,7 @@ struct CommitAttributionRuleTests {
|
|||||||
let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
|
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 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 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)
|
#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
|
// "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 file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
|
||||||
let receipts = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: true)]
|
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)
|
let split = CommitAttribution.split(changed, under: fixture.root, receipts: receipts)
|
||||||
#expect(split.heal == changed)
|
#expect(split.heal == changed)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Kanban
|
@testable import Kanban
|
||||||
|
|
||||||
/// **The semantic commit-message engine** (06-history-undo.md ▸ Commit messages) — the vocabulary,
|
/// **Change narrator — the semantic message spec** (06-history-undo.md ▸ Commit messages) — the
|
||||||
/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external
|
/// vocabulary, the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the
|
||||||
/// gap the section exists to close.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees — a plain
|
||||||
/// content comparison, since nothing here has a repository to ask.
|
/// 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 old = files(under: before)
|
||||||
let new = files(under: after)
|
let new = files(under: after)
|
||||||
var paths: [GitChangedPath] = []
|
var paths: [ChangedPath] = []
|
||||||
for (path, data) in new where old[path] != data {
|
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".
|
// `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,
|
path: path,
|
||||||
isDeletion: false,
|
isDeletion: false,
|
||||||
isRename: 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 {
|
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 }
|
return paths.sorted { $0.path < $1.path }
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
|
|||||||
private func compose(
|
private func compose(
|
||||||
board: (WriterFixture) throws -> Void = baseBoard,
|
board: (WriterFixture) throws -> Void = baseBoard,
|
||||||
change: (WriterFixture) throws -> Void,
|
change: (WriterFixture) throws -> Void,
|
||||||
authorship: CommitAuthorship = .user,
|
authorship: ChangeAuthorship = .user,
|
||||||
renames: Set<String> = [],
|
renames: Set<String> = [],
|
||||||
guideText: String? = nil
|
guideText: String? = nil
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
@@ -94,7 +94,7 @@ private func compose(
|
|||||||
|
|
||||||
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
||||||
renames.contains(path.path)
|
renames.contains(path.path)
|
||||||
? GitChangedPath(
|
? ChangedPath(
|
||||||
path: path.path,
|
path: path.path,
|
||||||
isDeletion: path.isDeletion,
|
isDeletion: path.isDeletion,
|
||||||
isRename: true,
|
isRename: true,
|
||||||
@@ -102,7 +102,7 @@ private func compose(
|
|||||||
)
|
)
|
||||||
: path
|
: path
|
||||||
}
|
}
|
||||||
return CommitMessageEngine.message(for: CommitMessageRequest(
|
return ChangeNarrator.narrative(for: ChangeNarrationRequest(
|
||||||
boardRoot: after.root,
|
boardRoot: after.root,
|
||||||
changedPaths: paths,
|
changedPaths: paths,
|
||||||
authorship: authorship,
|
authorship: authorship,
|
||||||
@@ -129,7 +129,7 @@ private func body(of message: String) -> [String] {
|
|||||||
|
|
||||||
// MARK: - One event, one subject
|
// 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 {
|
struct CommitMessageSingleEventTests {
|
||||||
|
|
||||||
@Test("Adding a card names the card, with its destination as the detail")
|
@Test("Adding a card names the card, with its destination as the detail")
|
||||||
@@ -262,7 +262,7 @@ struct CommitMessageSingleEventTests {
|
|||||||
|
|
||||||
// MARK: - The trash pair
|
// 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 {
|
struct CommitMessageTrashTests {
|
||||||
|
|
||||||
@Test("A move into .trash/ is Delete")
|
@Test("A move into .trash/ is Delete")
|
||||||
@@ -337,7 +337,7 @@ struct CommitMessageTrashTests {
|
|||||||
|
|
||||||
// MARK: - Folding
|
// MARK: - Folding
|
||||||
|
|
||||||
@Suite("Commit messages ▸ folding")
|
@Suite("Change narrator ▸ folding")
|
||||||
struct CommitMessageFoldingTests {
|
struct CommitMessageFoldingTests {
|
||||||
|
|
||||||
@Test("Several of one kind fold, and a shared destination survives into the subject")
|
@Test("Several of one kind fold, and a shared destination survives into the subject")
|
||||||
@@ -395,7 +395,7 @@ struct CommitMessageFoldingTests {
|
|||||||
|
|
||||||
// MARK: - The external gap
|
// MARK: - The external gap
|
||||||
|
|
||||||
@Suite("Commit messages ▸ the external gap, closed")
|
@Suite("Change narrator ▸ the external gap, closed")
|
||||||
struct CommitMessageExternalSurfaceTests {
|
struct CommitMessageExternalSurfaceTests {
|
||||||
|
|
||||||
@Test("Labels, assignees and due dates each compose a named subject")
|
@Test("Labels, assignees and due dates each compose a named subject")
|
||||||
@@ -469,13 +469,13 @@ struct CommitMessageExternalSurfaceTests {
|
|||||||
func originIsNotInTheProse() throws {
|
func originIsNotInTheProse() throws {
|
||||||
// "Origin lives in the author field (structural attribution), not in message prose — a foreign
|
// "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.
|
// 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 compose(change: { fixture in
|
||||||
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
|
||||||
}, authorship: authorship)
|
}, authorship: authorship)
|
||||||
}
|
}
|
||||||
let app = try move(.user)
|
let app = try move(.user)
|
||||||
let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "[email protected]")))
|
let foreign = try move(.foreign("Lanework External"))
|
||||||
let heal = try move(.heal)
|
let heal = try move(.heal)
|
||||||
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
|
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
|
||||||
#expect(foreign == app)
|
#expect(foreign == app)
|
||||||
@@ -485,7 +485,7 @@ struct CommitMessageExternalSurfaceTests {
|
|||||||
|
|
||||||
// MARK: - Bookkeeping
|
// MARK: - Bookkeeping
|
||||||
|
|
||||||
@Suite("Commit messages ▸ bookkeeping composes nothing")
|
@Suite("Change narrator ▸ bookkeeping composes nothing")
|
||||||
struct CommitMessageBookkeepingTests {
|
struct CommitMessageBookkeepingTests {
|
||||||
|
|
||||||
@Test("A bumped modified stamp is not an event")
|
@Test("A bumped modified stamp is not an event")
|
||||||
@@ -496,7 +496,7 @@ struct CommitMessageBookkeepingTests {
|
|||||||
modified: "2026-07-31T12:00:00Z"
|
modified: "2026-07-31T12:00:00Z"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#expect(message == CommitMessageEngine.unnamedSubject)
|
#expect(message == ChangeNarrator.unnamedSubject)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A renumber's rescale preserves sequence and composes nothing")
|
@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.card1, in: Ident.lane1, order: "16384", title: "Fix login")
|
||||||
try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
|
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")
|
@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"
|
"---\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")
|
@Test("A renumber batches with the insert that triggered it")
|
||||||
@@ -537,7 +537,7 @@ struct CommitMessageBookkeepingTests {
|
|||||||
|
|
||||||
// MARK: - Titles
|
// MARK: - Titles
|
||||||
|
|
||||||
@Suite("Commit messages ▸ titles")
|
@Suite("Change narrator ▸ titles")
|
||||||
struct CommitMessageTitleTests {
|
struct CommitMessageTitleTests {
|
||||||
|
|
||||||
@Test("Titles truncate in subjects only; bodies carry them whole")
|
@Test("Titles truncate in subjects only; bodies carry them whole")
|
||||||
@@ -568,7 +568,7 @@ struct CommitMessageTitleTests {
|
|||||||
|
|
||||||
// MARK: - Non-snapshot paths
|
// MARK: - Non-snapshot paths
|
||||||
|
|
||||||
@Suite("Commit messages ▸ non-snapshot files")
|
@Suite("Change narrator ▸ non-snapshot files")
|
||||||
struct CommitMessagePathEventTests {
|
struct CommitMessagePathEventTests {
|
||||||
|
|
||||||
@Test("A stray composes its own path-shaped event, and several fold")
|
@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)
|
let staged = changedPaths(from: before.root, to: after.root)
|
||||||
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
|
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
|
||||||
let message = CommitMessageEngine.message(for: CommitMessageRequest(
|
let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
|
||||||
boardRoot: after.root,
|
boardRoot: after.root,
|
||||||
changedPaths: staged,
|
changedPaths: staged,
|
||||||
authorship: .user,
|
authorship: .user,
|
||||||
@@ -651,7 +651,7 @@ struct CommitMessagePathEventTests {
|
|||||||
|
|
||||||
// MARK: - The comment verb family
|
// MARK: - The comment verb family
|
||||||
|
|
||||||
@Suite("Commit messages ▸ the comment verb family")
|
@Suite("Change narrator ▸ the comment verb family")
|
||||||
struct CommitMessageCommentTests {
|
struct CommitMessageCommentTests {
|
||||||
|
|
||||||
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
|
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
|
||||||
@@ -798,7 +798,7 @@ struct CommitMessageCommentTests {
|
|||||||
let message = try compose { fixture in
|
let message = try compose { fixture in
|
||||||
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
||||||
}
|
}
|
||||||
#expect(message != CommitMessageEngine.unnamedSubject)
|
#expect(message != ChangeNarrator.unnamedSubject)
|
||||||
#expect(!message.contains("comments/"))
|
#expect(!message.contains("comments/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,7 +926,7 @@ struct CommitMessageCommentTests {
|
|||||||
|
|
||||||
// MARK: - Repair
|
// MARK: - Repair
|
||||||
|
|
||||||
@Suite("Commit messages ▸ Repair")
|
@Suite("Change narrator ▸ Repair")
|
||||||
struct CommitMessageRepairTests {
|
struct CommitMessageRepairTests {
|
||||||
|
|
||||||
/// The remint's exact shape, assembled rather than provoked.
|
/// 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
|
/// 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
|
/// 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.
|
/// 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()
|
let before = try WriterFixture()
|
||||||
defer { before.tearDown() }
|
defer { before.tearDown() }
|
||||||
let after = try WriterFixture()
|
let after = try WriterFixture()
|
||||||
@@ -950,11 +950,11 @@ struct CommitMessageRepairTests {
|
|||||||
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
|
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
|
||||||
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
||||||
path.path == arrived
|
path.path == arrived
|
||||||
? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
|
? ChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
|
||||||
: path
|
: 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,
|
boardRoot: after.root,
|
||||||
changedPaths: paths,
|
changedPaths: paths,
|
||||||
authorship: authorship,
|
authorship: authorship,
|
||||||
@@ -977,7 +977,7 @@ struct CommitMessageRepairTests {
|
|||||||
|
|
||||||
// MARK: - The root commit
|
// MARK: - The root commit
|
||||||
|
|
||||||
@Suite("Commit messages ▸ the root commit")
|
@Suite("Change narrator ▸ the root commit")
|
||||||
struct CommitMessageRootCommitTests {
|
struct CommitMessageRootCommitTests {
|
||||||
|
|
||||||
@Test("The repository's first commit has the one fixed subject")
|
@Test("The repository's first commit has the one fixed subject")
|
||||||
@@ -985,7 +985,7 @@ struct CommitMessageRootCommitTests {
|
|||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try baseBoard(fixture)
|
try baseBoard(fixture)
|
||||||
let message = CommitMessageEngine.message(for: CommitMessageRequest(
|
let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
|
||||||
boardRoot: fixture.root,
|
boardRoot: fixture.root,
|
||||||
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
|
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
|
||||||
authorship: .user,
|
authorship: .user,
|
||||||
Reference in New Issue
Block a user