Build the semantic commit-message engine

CommitMessageEngine replaces the interim composer as the wired
default: a pure total function from two snapshots + changed paths to
a message. Full vocabulary — Add / Delete / Move / Rename / Edit /
Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove;
Repair for the duplicate remint (detected as a heal-classed
rename-paired arrival whose id the previous snapshot never held —
the loader withholds duplicates, so the shape is a bare arrival);
the trash triple by diff shape alone (into .trash = Delete, out =
Restore, leaving the tree = Permanently delete); Relabel / Assign /
Set due date plus the named generic for custom keys. Plural folding
with shared destinations, implied events as body bullets never
subjects, ~40-char subject truncation, "(untitled)". Bookkeeping
(sequence-preserving renumbers, stamps, backfilled kind) composes
nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md
reads "Update agent guide (vN)" via the marker line (the m10 card's
deferred bullet lands here), everything else "Update '<path>'".

The comment verb family per 01's ruling (comments shipped, so 06
gains the verbs): Comment on / Edit comment on / Delete comment on /
Draft comment on / Permanently delete comment on '<card>', grouped
one event per comment folder, classified ahead of the model-silence
rules, card title resolved from either snapshot. GIT_DELTA_ADDED is
surfaced as GitChangedPath.isArrival — post vs edit is unanswerable
from snapshots that exclude comments by ruling. A card moving with
its thread swallows the comment events (implied-events one level
down).

The previous snapshot is HEAD's tree, materialized per flush into a
temp dir (index.md blobs in full, other blobs zero-byte — the model
reads attachment names, never bytes) and re-parsed through the one
BoardLoader; never a value carried forward. changedPaths is a hard
filter per split commit, which also earns the stage-around and kills
phantom events. Launch catch-up and foreign windows compose through
the same engine.

48 new tests (35 pure + comment family + engine-level); 2293 tests /
394 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 14:54:17 -04:00
parent 3c07c26fda
commit 563999655f
8 changed files with 2479 additions and 50 deletions
+824
View File
@@ -0,0 +1,824 @@
import Foundation
import Testing
@testable import Kanban
/// **The semantic commit-message engine** (06-history-undo.md Commit messages) the vocabulary,
/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external
/// gap the section exists to close.
///
/// **No repository anywhere in this file.** The composer is "a pure, testable function": two board
/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and
/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a
/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path
/// list is derived by comparing the two trees which is what git would have reported, arrived at
/// without git. The engine-level half that a real commit carries this message, by the real author
/// lives in `AutoCommitTests`.
// MARK: - Fixtures
/// The board both snapshots start as: three lanes, two cards in the first.
private func baseBoard(_ fixture: WriterFixture) throws {
try fixture.board(title: "Board")
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
try fixture.lane(Ident.lane3, order: "3072", title: "Done")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
}
/// A UUID-shaped name `Ident` does not already spend on the base board the trash suites need one
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
/// would rightly withhold.
private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
/// Every file under a root, keyed by its board-root-relative path in git's spelling.
private func files(under root: URL) -> [String: Data] {
var found: [String: Data] = [:]
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: root.path) else { return found }
for case let relative as String in walker {
let url = root.appendingPathComponent(relative)
var isDirectory: ObjCBool = false
guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue
else { continue }
found[relative] = (try? Data(contentsOf: url)) ?? Data()
}
return found
}
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees a plain
/// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
let old = files(under: before)
let new = files(under: after)
var paths: [GitChangedPath] = []
for (path, data) in new where old[path] != data {
// `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it".
paths.append(GitChangedPath(
path: path,
isDeletion: false,
isRename: false,
isArrival: old[path] == nil
))
}
for path in old.keys where new[path] == nil {
paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false))
}
return paths.sorted { $0.path < $1.path }
}
/// Builds the same board twice, changes one copy, and composes the message for the difference.
///
/// - Parameters:
/// - board: the state both snapshots start in.
/// - change: what happened applied to the "after" copy only.
/// - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there**
/// because 06 rules that it must not matter: "a foreign move reads 'Move card ' exactly like an
/// app-mediated one". The one suite that varies it proves exactly that.
/// - renames: paths the survey would have paired as renames, for the shapes that turn on it.
private func compose(
board: (WriterFixture) throws -> Void = baseBoard,
change: (WriterFixture) throws -> Void,
authorship: CommitAuthorship = .user,
renames: Set<String> = [],
guideText: String? = nil
) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try board(before)
try board(after)
try change(after)
let paths = changedPaths(from: before.root, to: after.root).map { path in
renames.contains(path.path)
? GitChangedPath(
path: path.path,
isDeletion: path.isDeletion,
isRename: true,
isArrival: path.isArrival
)
: path
}
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot(),
agentGuideText: guideText
))
}
private func subject(of message: String) -> String {
String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "")
}
private func body(of message: String) -> [String] {
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
guard lines.count > 2 else { return [] }
return Array(lines.dropFirst(2))
}
// MARK: - One event, one subject
@Suite("Commit messages ▸ a single event is the subject")
struct CommitMessageSingleEventTests {
@Test("Adding a card names the card, with its destination as the detail")
func addingACard() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == ["to Doing"])
}
@Test("A cross-lane move reads as a move — never delete plus add")
func movingACard() throws {
// The whole-board id match: the folder left one lane and arrived in another, and the
// composer is asked to tell that from a deletion beside an arrival.
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)")
}
#expect(subject(of: message) == "Move card 'Fix login' to Done")
#expect(body(of: message) == ["Todo → Done"])
}
@Test("A rename carries both names in the subject — 06's own form")
func renamingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Backlog")
}
#expect(message == "Rename lane 'Todo' → 'Backlog'")
}
@Test("An edited body is an Edit, and a restyle is a Restyle")
func editingAndRestyling() throws {
let edited = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.")
}
#expect(edited == "Edit card 'Fix login'")
let restyled = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
)
}
#expect(restyled == "Restyle card 'Fix login'")
}
@Test("A widened lane is a Resize")
func resizingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2)
}
#expect(message == "Resize lane 'Todo' to 2×")
}
@Test("An attachment composes Attach, named by the card it landed on")
func attachingAFile() throws {
let message = try compose { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
}
#expect(message == "Attach 'spec.pdf' to card 'Fix login'")
}
@Test("A removed attachment composes Remove")
func removingAFile() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf")
)
}
#expect(message == "Remove 'spec.pdf' from card 'Fix login'")
}
@Test("A repositioned card composes Reorder, naming its lane")
func reorderingCards() throws {
// A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else
// changes. "An order change that repositions an item among its siblings composes Reorder."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login")
}
#expect(message == "Reorder cards in Todo")
}
@Test("The board's own fields compose board events")
func boardLevelEvents() throws {
let renamed = try compose { fixture in
try fixture.board(title: "Q3 Plan")
}
#expect(renamed == "Rename board 'Board' → 'Q3 Plan'")
let described = try compose { fixture in
try fixture.board(title: "Board", body: "What this board is for.")
}
#expect(described == "Edit board description")
}
}
// MARK: - The trash pair
@Suite("Commit messages ▸ the trash pair, by diff shape alone")
struct CommitMessageTrashTests {
@Test("A move into .trash/ is Delete")
func intoTheTrashIsDelete() throws {
let message = try compose { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#expect(message == "Delete card 'Fix login'")
}
@Test("A move out of .trash/ is Restore")
func outOfTheTrashIsRestore() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
}
#expect(message == "Restore card 'Old idea'")
}
@Test("Leaving the tree entirely is Permanently delete")
func leavingTheTreeIsPermanent() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)"))
}
#expect(message == "Permanently delete card 'Old idea'")
}
@Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete")
func emptyTrashFolds() throws {
let purge = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "One")
try fixture.trashCard(Ident.card4, order: "2048", title: "Two")
try fixture.trashCard(spareIdentity, order: "3072", title: "Three")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash"))
}
#expect(subject(of: purge) == "Permanently delete 3 cards")
let delete = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.move("\(Ident.lane1)/\(card)", toTrash: card)
}
}
#expect(subject(of: delete) == "Delete 3 cards")
}
@Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets")
func aTrashedLaneKeepsTheSubject() throws {
// "Implied events don't steal the subject: deleting a lane with five cards reads 'Delete lane
// 'X'' with the card deletions as body bullets not 'Update board'." And the cards are
// *deleted*, not permanently deleted: they went into the trash inside their lane's folder.
let message = try compose { fixture in
try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
}
#expect(subject(of: message) == "Delete lane 'Todo'")
#expect(body(of: message) == [
"- Delete lane 'Todo'",
"- Delete card 'Fix login'",
"- Delete card 'Ship it'",
])
}
}
// MARK: - Folding
@Suite("Commit messages ▸ folding")
struct CommitMessageFoldingTests {
@Test("Several of one kind fold, and a shared destination survives into the subject")
func sharedDestinationsFold() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)")
}
}
#expect(subject(of: message) == "Move 3 cards to Done")
#expect(body(of: message).count == 3)
#expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done")
}
@Test("Destinations that disagree drop out of the subject rather than lying")
func disagreeingDestinationsDropOut() throws {
let message = try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
})
#expect(subject(of: message) == "Move 2 cards")
}
@Test("A genuinely mixed window is 'Update board' — with every event in the body")
func mixedWindowsFallBack() throws {
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout")
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == CommitMessageEngine.mixedSubject)
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Rename card 'Fix login' → 'Fix logout'",
])
}
@Test("A deleted lane's cards are body bullets, never the subject")
func impliedEventsDoNotStealTheSubject() throws {
let message = try compose { fixture in
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
}
#expect(subject(of: message) == "Permanently delete lane 'Todo'")
#expect(body(of: message) == [
"- Permanently delete lane 'Todo'",
"- Permanently delete card 'Fix login'",
"- Permanently delete card 'Ship it'",
])
}
}
// MARK: - The external gap
@Suite("Commit messages ▸ the external gap, closed")
struct CommitMessageExternalSurfaceTests {
@Test("Labels, assignees and due dates each compose a named subject")
func theReservedTrioComposes() throws {
func withKey(_ line: String) throws -> String {
try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n"
)
}
}
#expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'")
#expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'")
#expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
}
@Test("An unmodeled custom key composes a named generic — never a board-level shrug")
func customKeysComposeANamedGeneric() throws {
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n"
)
}
// Two custom keys, one event: the item is what is named, not the keys.
#expect(message == "Update card 'Fix login'")
}
@Test("A foreign change composes identically to an app-mediated one")
func originIsNotInTheProse() throws {
// "Origin lives in the author field (structural attribution), not in message prose a foreign
// move reads 'Move card ' exactly like an app-mediated one." Same change, all three classes.
func move(_ authorship: CommitAuthorship) throws -> String {
try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
}, authorship: authorship)
}
let app = try move(.user)
let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "[email protected]")))
let heal = try move(.heal)
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
#expect(foreign == app)
#expect(heal == app)
}
}
// MARK: - Bookkeeping
@Suite("Commit messages ▸ bookkeeping composes nothing")
struct CommitMessageBookkeepingTests {
@Test("A bumped modified stamp is not an event")
func stampsAreSilent() throws {
let message = try compose { fixture in
try fixture.card(
Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login",
modified: "2026-07-31T12:00:00Z"
)
}
#expect(message == CommitMessageEngine.mixedSubject)
}
@Test("A renumber's rescale preserves sequence and composes nothing")
func rescalesAreSilent() throws {
// Every rank multiplied, nobody repositioned 01-storage-format.md's renumber. "Sequence is
// what the diff compares, not raw `order` values."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
}
#expect(message == CommitMessageEngine.mixedSubject)
}
@Test("An on-touch heal's backfilled kind is not an event")
func theBackfilledKindIsSilent() throws {
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
)
}
#expect(message == CommitMessageEngine.mixedSubject)
}
@Test("A renumber batches with the insert that triggered it")
func aRenumberRidesWithItsInsert() throws {
// Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit
// reads as the insert, because the *sequence* of the cards that were already there is
// unchanged.
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in")
try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it")
}
#expect(subject(of: message) == "Add card 'Wedged in'")
}
}
// MARK: - Titles
@Suite("Commit messages ▸ titles")
struct CommitMessageTitleTests {
@Test("Titles truncate in subjects only; bodies carry them whole")
func truncationIsSubjectOnly() throws {
let long = String(repeating: "A", count: 60)
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long)
}
#expect(subject(of: message) == "Add 2 cards to Doing")
#expect(body(of: message).allSatisfy { $0.contains(long) })
let sole = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
}
#expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'")
}
@Test("An untitled item reads (untitled), never a bare pair of quotes")
func untitledIsRendered() throws {
let message = try compose { fixture in
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
}
#expect(message == "Add card '(untitled)'\n\nto Doing")
#expect(!message.contains("''"))
}
}
// MARK: - Non-snapshot paths
@Suite("Commit messages ▸ non-snapshot files")
struct CommitMessagePathEventTests {
@Test("A stray composes its own path-shaped event, and several fold")
func straysComposePathEvents() throws {
let one = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(one == "Update 'notes.txt'")
let several = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
try fixture.file(".gitignore", Data("*.tmp\n".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8))
}
#expect(subject(of: several) == "Update 3 files")
#expect(body(of: several) == [
"- Update '.gitignore'",
"- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'",
"- Update 'notes.txt'",
])
}
@Test("The agent guide composes its version, read from the marker line")
func theAgentGuideComposesItsVersion() throws {
let guide = "<!-- lanework-agent-guide v7 -->\nHow to work in this board.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(guide.utf8))
}, guideText: guide)
#expect(message == "Update agent guide (v7)")
}
@Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is")
func amarkerlessGuideIsJustAFile() throws {
let text = "My own notes for agents.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(text.utf8))
}, guideText: text)
#expect(message == "Update 'CLAUDE.md'")
}
@Test("Model events keep the subject; a stray rides along as a bullet")
func modelEventsKeepTheSubject() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Update 'notes.txt'",
])
}
@Test("A path this commit does not stage composes nothing — the stage-around, in the message")
func pathsOutsideTheCommitAreNotDescribed() throws {
// The split window's rule, and the open-Edit-session exclusion's: the composer describes the
// commit it is composing for, not everything that differs from HEAD.
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.")
try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
let staged = changedPaths(from: before.root, to: after.root)
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: staged,
authorship: .user,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
#expect(message == "Add card 'Write tests'\n\nto Doing")
}
}
// MARK: - The comment verb family
@Suite("Commit messages ▸ the comment verb family")
struct CommitMessageCommentTests {
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
private static let commentB = "cccccccc-0000-4000-8000-000000000002"
private static let commentC = "cccccccc-0000-4000-8000-000000000003"
/// A comment's `index.md` never parsed by the composer, which reads the family off path shape
/// alone ("comments are window-scoped, outside the board snapshot" 01-storage-format.md).
private static func commentText(_ body: String) -> Data {
Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8)
}
private static func thread(_ card: String, _ entry: String) -> String {
"\(Ident.lane1)/\(card)/comments/\(entry)/index.md"
}
@Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'")
func postingComposesComment() throws {
// The app's post is one rename: `comments/.draft/` `comments/<uuid>/`, restamped in the
// same bracket. The departing `.draft` end is silent under the rename rule; the arrival is
// the event.
let draft = Self.thread(Ident.card1, ".draft")
let posted = Self.thread(Ident.card1, Self.commentA)
let message = try compose(board: { fixture in
try baseBoard(fixture)
try fixture.file(draft, Self.commentText("Half a thought."))
}, change: { fixture in
try fixture.file(posted, Self.commentText("Half a thought."))
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft")
)
}, renames: [draft, posted])
#expect(message == "Comment on 'Fix login'")
}
@Test("A foreign writer's new comment folder composes the same words")
func foreignArrivalsComposeTheSame() throws {
// No rename to read: an agent simply wrote a folder. Origin-agnostic one level down.
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent."))
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'")
func editsComposeEditComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft."))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts."))
}
#expect(message == "Edit comment on 'Fix login'")
}
@Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival")
func deletionComposesDeleteComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted."))
} change: { fixture in
try fixture.moveFolder(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)",
to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)"
)
}
// Both halves are in the window and git paired neither of them; the arriving end speaks.
#expect(message == "Delete comment on 'Fix login'")
}
@Test("The close purge composes 'Permanently delete comment on ⟨card⟩'")
func purgeComposesPermanentDelete() throws {
// `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in
// DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug.
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md",
Self.commentText("Deleted a while ago.")
)
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash")
)
}
#expect(message == "Permanently delete comment on 'Fix login'")
}
@Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'")
func draftSavesComposeDraftComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing"))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more"))
}
#expect(message == "Draft comment on 'Fix login'")
}
@Test("Every file in one comment folder is one event")
func oneCommentIsOneEvent() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file."))
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png",
Data("png".utf8)
)
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Several comments fold on the card they landed on")
func commentsFoldOnTheirCard() throws {
let sameCard = try compose { fixture in
for comment in [Self.commentA, Self.commentB, Self.commentC] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
}
#expect(subject(of: sameCard) == "3 comments on 'Fix login'")
let twoCards = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note."))
}
#expect(subject(of: twoCards) == "Comment on 2 cards")
}
@Test("The card's title is resolved from the snapshot, untitled included")
func titlesResolveFromTheSnapshot() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
} change: { fixture in
try fixture.file(
"\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md",
Self.commentText("On a nameless card.")
)
}
#expect(message == "Comment on '(untitled)'")
}
@Test("A comment-only window never shrugs and never shows a raw path")
func aCommentOnlyWindowGetsItsOwnSubject() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(message != CommitMessageEngine.mixedSubject)
#expect(!message.contains("comments/"))
}
@Test("Model events keep the subject; a comment rides along as a bullet")
func modelEventsOutrankComments() throws {
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(subject(of: message) == "Move card 'Ship it' to Done")
#expect(body(of: message).contains("- Comment on 'Fix login'"))
}
@Test("A card carrying its thread into the trash is one event, not one per comment")
func aMovedCardSwallowsItsThread() throws {
// The implied-events rule, one level down: the comment files travelled because the card did.
let message = try compose { fixture in
try baseBoard(fixture)
for comment in [Self.commentA, Self.commentB] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
} change: { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#expect(message == "Delete card 'Fix login'")
}
}
// MARK: - Repair
@Suite("Commit messages ▸ Repair")
struct CommitMessageRepairTests {
/// The remint's exact shape, assembled rather than provoked.
///
/// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to
/// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the
/// arrival because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`),
/// its id was never in a board model at all. Provoking that by writing two folders with one id
/// would make the test depend on which twin the dedupe happened to keep; the shape the composer
/// is asked about is this, and it is stated directly.
private func remintMessage(authorship: CommitAuthorship) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login")
let departed = "\(Ident.lane1)/\(Ident.card3)/index.md"
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
let paths = changedPaths(from: before.root, to: after.root).map { path in
path.path == arrived
? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
: path
} + [GitChangedPath(path: departed, isDeletion: true, isRename: true)]
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
}
@Test("A heal window's folder remint reads as Repair, not as an arrival")
func theRemintComposesRepair() throws {
#expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'")
}
@Test("The same shape outside a heal window is an ordinary arrival")
func repairIsHealOnly() throws {
#expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'")
}
}
// MARK: - The root commit
@Suite("Commit messages ▸ the root commit")
struct CommitMessageRootCommitTests {
@Test("The repository's first commit has the one fixed subject")
func theRootCommitIsFixed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try baseBoard(fixture)
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: fixture.root,
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
authorship: .user,
isRootCommit: true,
snapshot: try fixture.snapshot(),
previousSnapshot: nil
))
#expect(message == GitRepository.initialCommitSubject)
}
}