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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user