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:
@@ -85,9 +85,11 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
public var holdRecheckInterval: Duration = .seconds(15)
|
||||
|
||||
/// **What a commit says** — the seam the semantic composer plugs into (next card).
|
||||
/// **What a commit says** — the seam, holding the semantic composer by default
|
||||
/// (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 = InterimCommitMessage()
|
||||
public var composer: any CommitMessageComposing = SemanticCommitMessage()
|
||||
|
||||
/// 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.
|
||||
@@ -225,6 +227,12 @@ public final class GitAutoCommitter {
|
||||
/// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a
|
||||
/// version of somebody's file with no commit to recover it from).
|
||||
///
|
||||
/// **The semantic composer widened that bound**, and it is recorded rather than discovered: this
|
||||
/// flush now also materializes HEAD's tree and reads it back through `BoardLoader`
|
||||
/// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than
|
||||
/// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the
|
||||
/// commit that preserves somebody else's version, would be the worst message in the trail.
|
||||
///
|
||||
/// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns
|
||||
/// about foreign changes from landed reloads, so a write that lands inside the watcher's own
|
||||
/// debounce is not yet known to be pending. Bounded by that debounce, and the same window
|
||||
@@ -370,18 +378,91 @@ public final class GitAutoCommitter {
|
||||
|
||||
return GitCommitOperation.perform(
|
||||
at: input.boardRoot,
|
||||
commits: plan(changed, reading: reading, input: input)
|
||||
commits: plan(
|
||||
changed,
|
||||
reading: reading,
|
||||
input: input,
|
||||
composition: composition(for: changed, input: input)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - What the composer is handed
|
||||
|
||||
/// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural
|
||||
/// diff of two board snapshots — last-committed vs. current").
|
||||
///
|
||||
/// 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.
|
||||
private struct Composition: Sendable {
|
||||
var previous: BoardModel?
|
||||
var current: BoardModel?
|
||||
var agentGuideText: String?
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// **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],
|
||||
input: FlushInput
|
||||
) -> Composition {
|
||||
var composition = Composition()
|
||||
if changed.contains(where: { $0.path == AgentGuide.filename }) {
|
||||
composition.agentGuideText = try? String(
|
||||
contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename),
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
// **The comment family needs a board but not a diff.** Comments are outside the snapshot
|
||||
// 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 namesACard = changed.contains { CommentPath.classify($0.path) != nil }
|
||||
guard touchesModel || namesACard else { return composition }
|
||||
|
||||
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
|
||||
// a real configuration (`HistoryStore.compose` without a session, every engine-level test), and
|
||||
// a composer handed no current board could only ever shrug. Loading here rather than in
|
||||
// `makeInput` keeps the read off the main actor, where every other read in this flush already
|
||||
// is.
|
||||
composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model)
|
||||
guard touchesModel else { return composition }
|
||||
composition.previous = GitHeadSnapshot.load(at: input.boardRoot)
|
||||
return composition
|
||||
}
|
||||
|
||||
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
||||
private nonisolated static func plan(
|
||||
_ changed: [GitChangedPath],
|
||||
reading: GitRepositoryReading,
|
||||
input: FlushInput
|
||||
input: FlushInput,
|
||||
composition: Composition
|
||||
) -> [PlannedCommit] {
|
||||
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
||||
|
||||
func request(
|
||||
_ paths: [GitChangedPath],
|
||||
_ authorship: CommitAuthorship,
|
||||
isRootCommit: Bool = false
|
||||
) -> CommitMessageRequest {
|
||||
CommitMessageRequest(
|
||||
boardRoot: input.boardRoot,
|
||||
changedPaths: paths,
|
||||
authorship: authorship,
|
||||
isRootCommit: isRootCommit,
|
||||
snapshot: composition.current,
|
||||
previousSnapshot: composition.previous,
|
||||
agentGuideText: composition.agentGuideText
|
||||
)
|
||||
}
|
||||
|
||||
// **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole
|
||||
// tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed
|
||||
// snapshot to diff against". Splitting a repository's first commit three ways by the
|
||||
@@ -392,13 +473,7 @@ public final class GitAutoCommitter {
|
||||
guard !reading.isUnborn else {
|
||||
return [PlannedCommit(
|
||||
paths: changed.map(\.path),
|
||||
message: input.composer.message(for: CommitMessageRequest(
|
||||
boardRoot: input.boardRoot,
|
||||
changedPaths: changed,
|
||||
authorship: .user,
|
||||
isRootCommit: true,
|
||||
snapshot: input.snapshot
|
||||
)),
|
||||
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
|
||||
author: user,
|
||||
committer: user
|
||||
)]
|
||||
@@ -424,13 +499,7 @@ public final class GitAutoCommitter {
|
||||
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
||||
return PlannedCommit(
|
||||
paths: group.paths.map(\.path),
|
||||
message: input.composer.message(for: CommitMessageRequest(
|
||||
boardRoot: input.boardRoot,
|
||||
changedPaths: group.paths,
|
||||
authorship: authorship,
|
||||
isRootCommit: false,
|
||||
snapshot: input.snapshot
|
||||
)),
|
||||
message: input.composer.message(for: request(group.paths, authorship)),
|
||||
author: author,
|
||||
committer: user
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user