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
159 lines
7.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|