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:
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user