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