Files
lanework/Kanban/Git/GitHeadSnapshot.swift
rzen 563999655f 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
2026-07-31 14:54:17 -04:00

159 lines
7.9 KiB
Swift

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
}
}
}
}