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:
@@ -103,7 +103,8 @@ struct AutoCommitDebounceTests {
|
||||
|
||||
#expect(committer.commitCount == 1)
|
||||
#expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it")
|
||||
#expect(try headSubject(at: fixture.root) == InterimCommitMessage.fallbackSubject)
|
||||
// The message is the semantic composer's, end to end — no placeholder anywhere in the path.
|
||||
#expect(try headSubject(at: fixture.root) == "Add card 'Second'")
|
||||
}
|
||||
|
||||
@Test("A burst of changes inside one window is one commit, not one per change")
|
||||
@@ -893,6 +894,151 @@ struct EditSessionBoundaryTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Semantic messages, through the whole engine
|
||||
|
||||
/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is
|
||||
/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2,
|
||||
/// carries the composed message — HEAD's tree read for the last-committed half, the working tree for
|
||||
/// the current one, the split's own paths narrowing each message to its own commit.
|
||||
@MainActor
|
||||
@Suite("Auto-commit ▸ semantic messages")
|
||||
struct AutoCommitMessageTests {
|
||||
|
||||
@Test("A foreign change composes identically to an app-mediated one — only the author differs")
|
||||
func originIsNotInTheProse() async throws {
|
||||
// 06 ▸ The external gap, closed: "Origin lives in the author field (structural attribution),
|
||||
// not in message prose." Two boards, the same rename, one vouched for and one not.
|
||||
func rename(vouchedFor: Bool) async throws -> CommitRecord {
|
||||
let (fixture, git, ledger) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
let text = plain(order: "1024", title: "Renamed")
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", text)
|
||||
if vouchedFor {
|
||||
ledger.recordWrite(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||
.appendingPathComponent(BoardLoader.indexFileName),
|
||||
text: text
|
||||
)
|
||||
committer.noteWriteBracketClosed()
|
||||
} else {
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
}
|
||||
await committer.flushNow()
|
||||
return try #require(try history(at: fixture.root).first)
|
||||
}
|
||||
|
||||
let app = try await rename(vouchedFor: true)
|
||||
let foreign = try await rename(vouchedFor: false)
|
||||
#expect(app.subject == "Rename card 'First' → 'Renamed'")
|
||||
#expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design")
|
||||
// …and the author is the only thing that differs.
|
||||
#expect(app.authorEmail != CommitAttribution.externalAuthorEmail)
|
||||
#expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail)
|
||||
}
|
||||
|
||||
@Test("Board-open catch-up carries a real composed message, not a placeholder")
|
||||
func launchCatchUpComposes() async throws {
|
||||
// "Changes found pending at board open diff HEAD's tree against the working tree through the
|
||||
// same composer, instead of committing blind" (06). Nothing signals this window: no reload
|
||||
// landed, no bracket closed, and no snapshot was ever handed to the committer — the previous
|
||||
// board can only have come from HEAD.
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed"))
|
||||
committer.start()
|
||||
|
||||
try await waitUntil { committer.commitCount == 1 }
|
||||
#expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'")
|
||||
#expect(isClean(at: fixture.root))
|
||||
}
|
||||
|
||||
@Test("The guide write auto-commits as 'Update agent guide (vN)'")
|
||||
func theGuideComposesItsVersion() async throws {
|
||||
// The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker
|
||||
// line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site.
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
// An older guide, committed — so the window is a genuine guide *upgrade*: HEAD's bytes carry
|
||||
// v1 and the working tree's carry the version this build ships.
|
||||
try fixture.file(AgentGuide.filename, Data("<!-- lanework-agent-guide v1 -->\nOld.\n".utf8))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
#expect(try headSubject(at: fixture.root) == "Update agent guide (v1)")
|
||||
|
||||
_ = try AgentGuide.install(atBoardRoot: fixture.root)
|
||||
committer.noteWriteBracketClosed()
|
||||
await committer.flushNow()
|
||||
|
||||
#expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))")
|
||||
}
|
||||
|
||||
@Test("A split window's two commits each describe only their own paths")
|
||||
func eachCommitDescribesItsOwnPaths() async throws {
|
||||
// Both messages compose against the same HEAD, so the only thing that can keep them apart is
|
||||
// the changed-path list each commit stages — the filter, proved end to end.
|
||||
let (fixture, git, ledger) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent"))
|
||||
|
||||
let text = plain(order: "2048", title: "Added by the user")
|
||||
let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
|
||||
ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text)
|
||||
|
||||
committer.noteWriteBracketClosed()
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
|
||||
let log = try history(at: fixture.root)
|
||||
#expect(committer.commitCount == 2)
|
||||
// Newest first: the user's overwrite lands after the foreign version it might have buried.
|
||||
#expect(log.first?.subject == "Add card 'Added by the user'")
|
||||
#expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'")
|
||||
#expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
|
||||
}
|
||||
|
||||
@Test("A comment lands in the trail by its own verb, through real libgit2")
|
||||
func commentsComposeTheirFamily() async throws {
|
||||
// The one part of the comment family that cannot be proved without a repository: "is this
|
||||
// path new" is `GIT_DELTA_ADDED`, read off the real diff — the fact that tells a post from an
|
||||
// edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema).
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001"
|
||||
|
||||
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
#expect(try headSubject(at: fixture.root) == "Comment on 'First'")
|
||||
|
||||
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
#expect(try headSubject(at: fixture.root) == "Edit comment on 'First'")
|
||||
}
|
||||
|
||||
@Test("A stray-only window names the stray rather than shrugging")
|
||||
func straysAreNamed() async throws {
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
try fixture.file("notes.txt", Data("scratch\n".utf8))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
|
||||
#expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Attribution, as a pure function
|
||||
|
||||
@Suite("Auto-commit ▸ attribution rules")
|
||||
|
||||
Reference in New Issue
Block a user