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
+50 -25
View File
@@ -25,13 +25,18 @@ public enum CommitAuthorship: Sendable, Equatable {
/// Everything the message engine is handed for one commit.
///
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
/// composer the next card plugs into it without reshaping the engine: it can start reading
/// `snapshot` and HEAD's tree the day it lands, and any input it turns out to need joins this type
/// rather than every call site.
/// composer plugs into it without reshaping the engine: any input it turns out to need joins this
/// type rather than every call site. It did need two `previousSnapshot` and `agentGuideText`, both
/// below and that is exactly what this shape was for.
///
/// **Everything here is a value, and that is the design.** The composer (`CommitMessageEngine`) reads
/// 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
/// 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.
public struct CommitMessageRequest: Sendable {
/// The board this is a commit in and, for the composer card, where HEAD's tree is read from
/// for the last-committed half of its diff.
/// The board this is a commit in.
public let boardRoot: URL
/// **The changed-path list** (06 Commit messages Non-snapshot files commit too: "beside the
@@ -46,23 +51,46 @@ public struct CommitMessageRequest: Sendable {
/// ("Initial board state", 06 Rules Abnormal repo states).
public let isRootCommit: Bool
/// The board as the app last read it, or `nil` where no store is attached (a storeless
/// committer, a test). The current half of the composer's "last-committed vs. current" diff; the
/// other half is HEAD's tree, which the composer reads for itself.
/// **The current half** of the composer's "last-committed vs. current" diff the board as the
/// app last read it, or, where no store is attached, as the flush read it off disk itself.
///
/// `nil` only when neither could answer: a board whose working tree does not load at all, which
/// is a commit that will have to be described by its paths.
public let snapshot: BoardModel?
/// **The last-committed half**: the board as HEAD's tree has it (`GitHeadSnapshot`).
///
/// `nil` on an unborn HEAD where `isRootCommit` already says everything and on a HEAD whose
/// tree does not load as a board. It is read from the repository rather than carried forward from
/// the last commit the 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?
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it the one
/// non-snapshot file with a subject of its own ("Update agent guide (vN)", 06 Commit messages).
///
/// The *text*, not the version: N is "a pure function of file content"
/// (`AgentGuide.installedVersion`), and keeping the parse on the composer's side is what keeps
/// that rule where the message vocabulary is. `nil` when the guide is not in this commit, cannot
/// be read, or has been deleted.
public let agentGuideText: String?
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
authorship: CommitAuthorship,
isRootCommit: Bool,
snapshot: BoardModel?
snapshot: BoardModel?,
previousSnapshot: BoardModel? = nil,
agentGuideText: String? = nil
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
self.authorship = authorship
self.isRootCommit = isRootCommit
self.snapshot = snapshot
self.previousSnapshot = previousSnapshot
self.agentGuideText = agentGuideText
}
}
@@ -70,11 +98,11 @@ public struct CommitMessageRequest: Sendable {
/// **What a commit says** (06-history-undo.md Commit messages).
///
/// The real implementation is the next card's: "a pure, testable 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, and the trash pair. None of that
/// exists yet; what exists is this protocol, so that arriving card is one type conforming here
/// rather than a change to the engine that calls it.
/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable
/// 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,
/// and the trash pair. The protocol survives its interim purpose because it is still what lets a test
/// inject a fake and assert *when* a message was asked for without asserting what it said.
///
/// `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
@@ -83,22 +111,19 @@ public protocol CommitMessageComposing: Sendable {
func message(for request: CommitMessageRequest) -> String
}
// MARK: - The interim
// MARK: - The wired composer
/// **The placeholder message**, deliberately the *fallback* 06 already names rather than an
/// invention: "genuinely mixed windows fall back to 'Update board'".
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
///
/// So the trail an interim build writes is a trail the composer card only ever makes *more*
/// specific no message written today becomes wrong tomorrow, and the root commit's subject is
/// already the settled one.
public struct InterimCommitMessage: CommitMessageComposing {
/// 06's own mixed-window fallback.
public static let fallbackSubject = "Update board"
/// A one-line conformance over `CommitMessageEngine`, 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
/// the seam takes an existential, and because a *named* default is what makes "the engine's composer
/// is the semantic one" assertable.
public struct SemanticCommitMessage: CommitMessageComposing {
public init() {}
public func message(for request: CommitMessageRequest) -> String {
request.isRootCommit ? GitRepository.initialCommitSubject : Self.fallbackSubject
CommitMessageEngine.message(for: request)
}
}
File diff suppressed because it is too large Load Diff
+87 -18
View File
@@ -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
)
+22 -5
View File
@@ -90,10 +90,21 @@ public struct GitChangedPath: Sendable, Equatable, Hashable {
/// deletion on disk that the window must not be demoted by.
public let isRename: Bool
public init(path: String, isDeletion: Bool, 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
}
}
@@ -331,7 +342,7 @@ enum GitCommitOperation {
var found: [String: GitChangedPath] = [:]
func record(_ path: String?, isDeletion: Bool, isRename: Bool) {
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
guard let path, !path.isEmpty else { return }
let existing = found[path]
found[path] = GitChangedPath(
@@ -339,7 +350,10 @@ enum GitCommitOperation {
// 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.
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
isRename: (existing?.isRename ?? false) || isRename
isRename: (existing?.isRename ?? false) || isRename,
// New wins, for the mirror of that reason: one delta calling a path an addition is
// enough to know HEAD did not have it, which is the whole content of the bit.
isArrival: (existing?.isArrival ?? false) || isArrival
)
}
@@ -350,9 +364,12 @@ enum GitCommitOperation {
record(string(delta.old_file.path), isDeletion: true, isRename: false)
case GIT_DELTA_RENAMED:
// Both ends, and neither is a deletion the window may be demoted by: the departure
// has to leave the index and the arrival has to enter it.
// has to leave the index and the arrival has to enter it. The arriving end is new at
// its path, which is what the comment family reads a post by.
record(string(delta.old_file.path), isDeletion: true, isRename: true)
record(string(delta.new_file.path), isDeletion: false, isRename: true)
record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true)
case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED:
record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: true)
default:
record(string(delta.new_file.path), isDeletion: false, isRename: false)
}
+158
View File
@@ -0,0 +1,158 @@
import Foundation
import libgit2
import os
// MARK: - GitHeadSnapshot
/// **The last-committed half of the composer's diff** (06-history-undo.md Commit messages: "a
/// structural diff of two board snapshots last-committed vs. current").
///
/// ### Why HEAD's tree, and not a snapshot carried forward
///
/// The engine could remember the board it committed last time and hand that back as "previous". It
/// deliberately does not, for four reasons, each of which is a case the carried value would get
/// wrong:
///
/// - **Launch catch-up has no previous to carry.** "Changes found pending at board open diff HEAD's
/// tree against the working tree through the same composer" (06) at open the app's only snapshot
/// is the one it just loaded, which already *contains* the pending changes. The previous state
/// exists nowhere but in the repository.
/// - **The app is not the only writer.** An agent that commits its own work moves HEAD without the
/// app writing anything; a carried snapshot would diff against a state that is already history.
/// - **A failed or skipped commit does not advance history.** A carried value would advance anyway
/// and silently under-describe the next window.
/// - **It is checkable.** "Last committed" is a fact the repository answers; a carried value is a
/// claim the engine makes about itself, and nothing would ever catch it drifting.
///
/// The cost is this file: HEAD's tree is materialized into a temporary directory and read back
/// through the one `BoardLoader`, so the previous snapshot is produced by exactly the machinery that
/// produced the current one. Re-parsing rather than re-deriving is the point two loaders would be
/// two definitions of what a board is.
///
/// ### What it writes
///
/// **`index.md` blobs in full; every other blob as a zero-byte placeholder.** The snapshot models
/// frontmatter, bodies and *attachment names* never attachment bytes so materializing a board's
/// images would copy megabytes per commit to answer a question about file names. Directories are
/// created so the shape the loader walks is the shape HEAD has.
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
/// the same synchronous scope, and no handle crosses an `await`. Called from inside the flush's
/// detached task, never from the main actor.
enum GitHeadSnapshot {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state `GitCommitOperation.startUp`'s twin and for its reason (a flush can
/// run when no `Repository` is alive, so this file cannot ride on SwiftGitX's refcount).
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
/// **The board as HEAD has it**, or `nil` when there is nothing to read one from: an unborn HEAD,
/// an unopenable repository, a tree with no board `index.md` in it.
///
/// `nil` is a *shrug*, not an error the composer that receives it simply has no previous half
/// and falls back to describing the commit by its paths. Nothing here can fail a commit.
nonisolated static func load(at boardRoot: URL) -> BoardModel? {
_ = startUp
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return nil }
defer { git_repository_free(repository) }
guard git_repository_head_unborn(repository) != 1 else { return nil }
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0, let tree = object else {
return nil
}
defer { git_tree_free(tree) }
let scratch = FileManager.default.temporaryDirectory
.appendingPathComponent("LaneworkHeadSnapshot-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: scratch) }
guard (try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)) != nil
else { return nil }
materialize(tree: tree, in: repository, into: scratch, depth: 0)
do {
return try BoardLoader.load(boardRoot: scratch).model
} catch {
// A HEAD whose tree the loader refuses a board committed before `index.md` existed, a
// schema from the future is simply not a previous snapshot. The window still commits;
// its message is composed from the paths alone.
logger.debug("HEAD's tree did not load as a board: \(error.description, privacy: .public)")
return nil
}
}
/// One tree level, recursively. Total and silent: a blob that cannot be read is skipped, because
/// a partial previous snapshot degrades one event's wording while a thrown error would cost the
/// commit its message entirely.
///
/// The depth cap is a guard against a pathological repository, not a statement about boards a
/// board is three levels deep, four counting `attachments/`.
private static func materialize(
tree: OpaquePointer,
in repository: OpaquePointer,
into directory: URL,
depth: Int
) {
guard depth < 8 else { return }
let manager = FileManager.default
for position in 0..<git_tree_entrycount(tree) {
guard let entry = git_tree_entry_byindex(tree, position),
let rawName = git_tree_entry_name(entry) else { continue }
let name = String(cString: rawName)
guard !name.isEmpty, name != ".", name != ".." else { continue }
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
// escape if it were not: refuse rather than interpret.
guard !name.contains("/") else { continue }
switch git_tree_entry_type(entry) {
case GIT_OBJECT_TREE:
var child: OpaquePointer?
guard let id = git_tree_entry_id(entry),
git_tree_lookup(&child, repository, id) == 0,
let child else { continue }
defer { git_tree_free(child) }
let folder = directory.appendingPathComponent(name, isDirectory: true)
guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
else { continue }
materialize(tree: child, in: repository, into: folder, depth: depth + 1)
case GIT_OBJECT_BLOB:
let file = directory.appendingPathComponent(name)
// **Content for `index.md`, a placeholder for everything else.** The loader reads
// frontmatter and bodies out of the first and only the *names* of the rest.
guard name == IntegrityRules.indexFileName else {
try? Data().write(to: file)
continue
}
var blob: OpaquePointer?
guard let id = git_tree_entry_id(entry),
git_blob_lookup(&blob, repository, id) == 0,
let blob else { continue }
defer { git_blob_free(blob) }
let size = Int(git_blob_rawsize(blob))
let bytes = git_blob_rawcontent(blob)
let data = (bytes != nil && size > 0)
? Data(bytes: bytes!, count: size)
: Data()
try? data.write(to: file)
default:
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
// in this app either (`BoardLoader.directoryCandidates` excludes links).
continue
}
}
}
}