Skip to content
zit Artifact [97b8ab297c]
Login

Artifact [97b8ab297c]

Artifact 97b8ab297c90c9c19cb27d4b9e2d6c3184e135863bfc89e30d1f151de2bdcc33:


//! The direct-commit core: a check-in built straight from its parent's
//! manifest plus a list of edits, with no working tree and no temporary
//! checkout. `commit --stdin` calls it; a resident caller (a daemon, a C ABI)
//! keeps a `Context` open and calls `commit` per request.
//!
//! The manifest it mints is the one the working-tree commit
//! (`cmd/checkout.zig`, `runCommitInner`) would mint for the same change:
//! same F-set composition (`compose.zig`), same tag cards, same P/D/U/C cards,
//! same delta dialect and sub-manifest construction, same skip-delta base.
//! The differential test in `checkin_e2e.zig` holds the two byte for byte.
//!
//! What makes it cheap is what it does NOT read: a sub-manifest parent is
//! edited through `submanifest_editor.TreeEditor`, and the repository view is
//! opened once per `Context`, not once per stage. Touching only the nodes on
//! the edited paths' routes holds for a RESIDENT caller, whose context
//! recorded the parent: its editor holds a canonical basis for that root and
//! its rename memo knows which carried cards still name an old path. A
//! one-shot caller pays a whole-tree read twice over: `scanRenames` loads
//! every node to find those cards, and the cold editor streams the whole tree
//! to repartition it (about 90 ms of a 42k-file tree, both together).
//!
//! It prints nothing and knows no exit codes: every refusal is a typed error
//! with its human-readable detail in a `Diag` the caller renders.

const std = @import("std");
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const storage = @import("../storage.zig");
const repostate = @import("../repostate.zig");
const repository = @import("../repository.zig");
const Repository = repository.Repository;
const editor = @import("../submanifest_editor.zig");
const LogTreeStore = @import("../log_tree_store.zig").LogTreeStore;
const store_open = @import("store_open.zig");
const storelock = @import("../storelock.zig");
const compose = @import("compose.zig");
const lookslike = @import("../lookslike.zig");
const ignore = @import("../ignore.zig");
const repocache = @import("../repocache.zig");
const control_paths = @import("../control_paths.zig");
const managed = @import("../managed.zig");

const digest_len = 32;
const sym_prefix = artifact.sym_tag_prefix;

/// The largest content one edit may carry: the storage frame ceiling.
pub const max_file_bytes: usize = managed.max_tracked_file_bytes;

/// The decoded-node budget of the context's tree editor. Enough to hold every
/// interior node and a working set of leaves of a very wide tree.
const tree_cache_bytes: usize = 64 << 20;

/// One change to the parent's file set.
pub const Edit = union(enum) {
    /// PATH's content becomes `bytes`. `mode` null keeps the parent's mode
    /// (regular for a new path).
    put: Put,
    /// PATH leaves the check-in.
    delete: []const u8,
    /// OLD becomes NEW, recorded as a rename (§4.7.6 old-name). `bytes` null
    /// keeps OLD's content; `mode` null keeps OLD's mode.
    rename: Rename,

    pub const Put = struct {
        path: []const u8,
        bytes: []const u8,
        mode: ?artifact.FileMode = null,
    };

    pub const Rename = struct {
        old: []const u8,
        new: []const u8,
        bytes: ?[]const u8 = null,
        mode: ?artifact.FileMode = null,
    };
};

/// The commit time: whole seconds since the epoch plus milliseconds, the D
/// card's writer form.
pub const Time = struct {
    epoch: i64,
    ms: u16 = 0,
};

/// The content checks and the R card, as the caller resolved them from the
/// repository's settings (`binary-glob`, `crlf-glob`, `encoding-glob`,
/// `repo-cksum`). Sub-manifest emission is not here: it is the repository's
/// own config, read under the lock by `commit` itself.
pub const Policy = struct {
    binary_glob: []const u8 = "",
    crlf_glob: []const u8 = "",
    encoding_glob: []const u8 = "",
    repo_cksum: bool = false,
};

/// Caller-supplied steps that need what the core does not own: a process to
/// run, a signing key. Each runs after the manifest is composed and before
/// anything is written, so a refusal leaves the store as it was.
pub const Hooks = struct {
    ctx: ?*anyopaque = null,
    /// The project's before-commit check. Refusal has been reported by the
    /// hook itself.
    before_commit: ?*const fn (ctx: ?*anyopaque) error{Refused}!void = null,
    /// Wraps the plain manifest (a clearsign). Null from the hook means "do
    /// not sign"; returned bytes are owned by `gpa`. The wrapped text must
    /// strip back to the plain manifest exactly, or the commit is refused.
    seal: ?*const fn (ctx: ?*anyopaque, gpa: std.mem.Allocator, plain: []const u8) error{ Refused, OutOfMemory }!?[]u8 = null,
};

pub const Request = struct {
    /// The check-in to build on, any name `resolve` accepts. Null: the newest
    /// open leaf of `branch` when that branch is open, else of the main
    /// branch; an empty repository records its first check-in.
    parent: ?[]const u8 = null,
    /// Land on this branch. Equal to the parent's branch, it changes nothing;
    /// otherwise the check-in starts it, under commit's rules for a new branch.
    branch: ?[]const u8 = null,
    user: []const u8,
    comment: []const u8,
    now: Time,
    edits: []const Edit,
    /// Build on a parent that is no longer its branch's leaf.
    allow_fork: bool = false,
    /// Waive the content checks. The before-commit hook is the caller's and
    /// reads its own copy of this choice.
    no_checks: bool = false,
    policy: Policy = .{},
    hooks: Hooks = .{},
};

/// An artifact name: 64 hex for SHA3-256, 40 for a SHA-1 parent.
pub const Name = struct {
    buf: [64]u8 = undefined,
    len: u8 = 0,

    pub fn of(hex: []const u8) Name {
        std.debug.assert(hex.len == 40 or hex.len == 64);
        var n: Name = .{ .len = @intCast(hex.len) };
        @memcpy(n.buf[0..hex.len], hex);
        return n;
    }

    pub fn slice(self: *const Name) []const u8 {
        return self.buf[0..self.len];
    }
};

pub const Outcome = union(enum) {
    /// A new check-in was published.
    recorded: Name,
    /// Every edit matched the parent: nothing was written; the parent's name.
    unchanged: Name,
    /// The composed check-in is already in the store, byte for byte: nothing
    /// was written; its name.
    existing: Name,
};

/// Human-readable detail for the error a call returned: one or more lines,
/// LF-separated, no trailing LF, each a sentence the caller may prefix with
/// its own name. Empty when there is nothing to add (a lock wait that already
/// spoke on stderr, an allocation failure). Bounded: a longer message is cut.
pub const Diag = struct {
    buf: [diag_max]u8 = undefined,
    len: usize = 0,

    pub const diag_max = 2048;

    pub fn text(self: *const Diag) []const u8 {
        return self.buf[0..self.len];
    }

    pub fn clear(self: *Diag) void {
        self.len = 0;
    }

    fn set(self: *Diag, comptime fmt: []const u8, args: anytype) void {
        var w: std.Io.Writer = .fixed(&self.buf);
        w.print(fmt, args) catch {};
        self.len = w.end;
    }
};

pub const Error = error{
    OutOfMemory,
    /// The writer lock was held past the wait; the lock already said so.
    StoreBusy,
    /// An I/O failure reading or writing the store.
    Storage,
    /// The store, or the parent check-in, does not decode.
    Corrupt,
    /// The store is not one this build can open (a pending import, an
    /// unreadable view).
    CannotOpen,
    /// No store at the directory given. Nothing in the detail: the caller
    /// knows how it named the directory and says so.
    NotARepo,
    /// A request that cannot be acted on as given: an empty edit list, a path
    /// a check-in cannot hold, two edits of one path, an empty branch name.
    InvalidRequest,
    /// `parent` names nothing, or several things, or not a check-in.
    ParentNotFound,
    /// No parent was named and the branch has no open leaf.
    NoOpenLeaf,
    /// `branch` on the first check-in, which starts the main branch.
    BranchOnRoot,
    /// `branch` names a branch that already has an open leaf.
    BranchExists,
    /// The parent is no longer its branch's leaf, and `allow_fork` is off.
    NotTip,
    /// An edit does not fit the parent's tree: a path under a file, a path
    /// that is a directory, a delete or rename of an absent path, a rename
    /// onto a present one, a symbolic link.
    PathConflict,
    /// Content larger than one artifact can hold.
    TooLarge,
    /// Content of a class the repository has not accepted.
    ContentRefused,
    /// A sub-manifest the parent names is not in the store.
    MissingNode,
    /// A content path the store's cone elides: the parent content is not here.
    ElidedByCone,
    HookRefused,
    SignFailed,
};

/// The parent's symbolic tags a branch-root check-in must cancel (repo-state
/// §6.2). §6.2: "Branch creation MUST also cancel every pre-existing active
/// sym-* tag on the branch-root check-in ... because symbolic tags are
/// otherwise INHERITED from a check-in's parent." Inheritance is propagation,
/// so the cancel set is exactly the parent's PROPAGATING (`*`) syms: a singleton
/// (`+`) sym never propagates, so the child does not inherit it and cancelling
/// it would emit a spurious non-canonical `-sym-X` for a tag the child never
/// held. `zit tag add` mints exactly such a singleton sym (REGRESSION B6), so
/// this exclusion is reachable locally now, not only via a synced
/// `T +sym-X *`. The sym of `branch`, the branch being established, is
/// excluded, since `composeCommitTags` re-adds it as a propagating tag. Pure;
/// each surfaced name is duped onto `arena` so it outlives the read view.
/// `entries` come from `RepoState.tagsOf`, which surfaces active rows only (no
/// cancels).
pub fn selectInheritedSyms(
    arena: std.mem.Allocator,
    entries: []const repostate.RepoState.TagEntry,
    branch: []const u8,
) error{OutOfMemory}![]const []const u8 {
    const want_sym = try std.mem.concat(arena, u8, &.{ sym_prefix, branch });
    var syms: std.ArrayList([]const u8) = .empty;
    errdefer syms.deinit(arena);
    for (entries) |e| {
        std.debug.assert(e.tagtype != .cancel); // tagsOf surfaces active rows only
        if (e.tagtype != .propagate) continue; // §6.2: only inherited (propagating) syms
        if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
        if (std.mem.eql(u8, e.name, want_sym)) continue; // re-established as a propagating tag
        try syms.append(arena, try arena.dupe(u8, e.name));
    }
    return syms.toOwnedSlice(arena);
}

/// The content classes a commit will not record silently.
pub const ContentFault = enum {
    binary,
    /// UTF-16 content, fossil's "Unicode" warning. One `encoding-glob`
    /// class with `invalid_utf8`, kept as two variants because the operator is
    /// told which of the two their file is.
    unicode,
    invalid_utf8,
    crlf,

    pub fn describe(self: ContentFault) []const u8 {
        return switch (self) {
            .binary => "contains NUL bytes (it looks binary)",
            .unicode => "is UTF-16 (Unicode)",
            .invalid_utf8 => "is not valid UTF-8",
            .crlf => "has CR/LF line endings",
        };
    }

    pub fn glob(self: ContentFault) []const u8 {
        return switch (self) {
            .binary => "binary-glob",
            .unicode, .invalid_utf8 => "encoding-glob",
            .crlf => "crlf-glob",
        };
    }
};

/// The resolved content policy for one commit: which files may carry
/// which content class, and whether the operator waived the check entirely.
/// The globs resolve through the ordinary tier chain, so a tracked
/// `.zit-settings/binary-glob` applies to a fresh clone with no consent step —
/// which is the whole reason these are DECLARATIVE rather than a
/// `before-commit` script, since that one runs for nobody until `zit trust`.
pub const ContentPolicy = struct {
    binary_glob: []const u8 = "",
    crlf_glob: []const u8 = "",
    /// Fossil's `encoding-glob`: the files whose encoding this project
    /// has accepted. Covers both encoding classes, as it does in fossil.
    encoding_glob: []const u8 = "",
    waived: bool = false,

    /// The fault this file trips, or null when nothing objects.
    ///
    /// A class is checked ONLY when its glob is set. An unset glob is "this
    /// project has no opinion", not "refuse everything": committing a PNG is
    /// ordinary, and a check that fired by default would make every repository
    /// that never asked for it pay a two-step dance. Declaring the glob is what
    /// opts a project in, and it reads naturally — `binary-glob = *.png` says
    /// PNGs are the binaries this project expects, so a stray `.o` is refused.
    ///
    /// This departs from fossil, which warns on binary content by default and
    /// uses its globs to SUPPRESS the warning. The departure is deliberate: a
    /// warning that fires on every ordinary commit is one nobody reads, and a
    /// refusal nobody asked for is worse. zit checks when told to, and then
    /// actually refuses — which is what makes it a policy rather than noise.
    ///
    /// The ORDER is fossil's branch order (checkin.c): binary, then Unicode
    /// (with or without CR), then invalid UTF-8, then CR/LF. A binary file
    /// usually also contains CR/LF, and naming it "binary" is the diagnostic
    /// the operator can act on; and a UTF-16 file carrying CR is exempt only
    /// when BOTH globs permit it, which falls out of checking encoding first.
    ///
    /// Binary is not merely first, it is TERMINAL — fossil's binary branch
    /// returns, so no later class is consulted for a blob with a NUL, whether
    /// or not `binary-glob` is declared. Without that, declaring `encoding-glob`
    /// alone would start refusing every PNG and zip in the tree under a glob
    /// that cannot exempt them, since binary formats are rarely valid UTF-8.
    /// Invalid UTF-8 is terminal the same way (fossil's `fHasInvalidUtf8`
    /// branch returns once `encoding-glob` permits the file), so a Latin-1
    /// file with CR/LF that `encoding-glob` names is never a CR/LF fault.
    /// UTF-16 is NOT terminal: fossil exempts Unicode-with-CR only when BOTH
    /// globs permit it, so the CR/LF row still runs for it.
    pub fn fault(self: ContentPolicy, path: []const u8, content: []const u8) ?ContentFault {
        if (self.waived) return null;
        const look = lookslike.classify(content);
        if (look.nul) return if (objects(self.binary_glob, path)) .binary else null;
        if (look.utf16 and objects(self.encoding_glob, path)) return .unicode;
        if (look.invalid_utf8) return if (objects(self.encoding_glob, path)) .invalid_utf8 else null;
        if (look.cr and objects(self.crlf_glob, path)) return .crlf;
        return null;
    }

    /// This project declared the class (the glob is set) and did not name this
    /// file. An empty glob is silence, and `*` is how the check is turned off.
    fn objects(list: []const u8, path: []const u8) bool {
        return list.len != 0 and !ignore.listMatches(list, path);
    }

    /// What Fossil would have prompted about and zit lets through when no
    /// glob is declared: said in the commit receipt, never a refusal.
    pub fn undeclared(self: ContentPolicy, content: []const u8) ?ContentFault {
        if (self.waived) return null;
        const look = lookslike.classify(content);
        // Same terminal binary branch as `fault`: a blob with a NUL is
        // reported as binary or not at all, never under another class's words.
        if (look.nul) return if (self.binary_glob.len == 0) .binary else null;
        if (self.encoding_glob.len == 0 and look.utf16) return .unicode;
        if (look.invalid_utf8) return if (self.encoding_glob.len == 0) .invalid_utf8 else null;
        if (self.crlf_glob.len == 0 and look.cr) return .crlf;
        return null;
    }
};

/// File contents a commit has captured but not yet stored. Preparation
/// runs without the writer lock, so it cannot append; what the store lacks
/// is held here and appended by the publish step, under the lock.
pub const PendingBlobs = struct {
    items: std.ArrayList(Item) = .empty,
    index: std.AutoHashMapUnmanaged([digest_len]u8, usize) = .empty,

    pub const Item = struct {
        digest: [digest_len]u8,
        bytes: []u8,
        /// The same path's blob in the parent: a delta-base hint, valid for
        /// as long as the store is only appended to.
        base: ?logstore.Rid,
    };

    pub fn deinit(self: *PendingBlobs, gpa: std.mem.Allocator) void {
        for (self.items.items) |it| gpa.free(it.bytes);
        self.items.deinit(gpa);
        self.index.deinit(gpa);
    }

    /// Takes ownership of `bytes`. Two paths with equal content keep one copy.
    pub fn add(self: *PendingBlobs, gpa: std.mem.Allocator, digest: [digest_len]u8, bytes: []u8, base: ?logstore.Rid) error{OutOfMemory}!void {
        const got = self.index.getOrPut(gpa, digest) catch |e| {
            gpa.free(bytes);
            return e;
        };
        if (got.found_existing) {
            gpa.free(bytes);
            return;
        }
        self.items.append(gpa, .{ .digest = digest, .bytes = bytes, .base = base }) catch |e| {
            _ = self.index.remove(digest);
            gpa.free(bytes);
            return e;
        };
        got.value_ptr.* = self.items.items.len - 1;
    }

    pub fn get(self: *const PendingBlobs, digest: []const u8) ?[]const u8 {
        // A commit captures under SHA3 only, so a SHA-1 name a parent carries
        // forward is never one of these.
        if (digest.len != digest_len) return null;
        const i = self.index.get(digest[0..digest_len].*) orelse return null;
        return self.items.items[i].bytes;
    }
};

/// The most primary-parent hops `skipBaseOf` walks before giving up on the
/// skip base, so a cycle in a damaged store or an absurd history cannot make
/// a commit spin. Far past any real history.
const skip_walk_max: u64 = 1 << 24;

/// The skip-delta base of a check-in made on `parent` (`logstore.skipBaseHeight`):
/// with the parent at height h along primary parents (root = 0), the new
/// check-in sits at n = h + 1 and bases on its primary ancestor at height
/// n & (n - 1), which is 2^ctz(n) - 1 hops above the parent. A branch counts
/// its height back through its fork point, and a merge along its primary
/// line only. Null when the walk passes `skip_walk_max`; the caller then
/// falls back to the parent. The height walk is O(height) over the in-memory
/// edges, since nothing cheaper records a primary-line height; at 1.5M
/// check-ins that is tens of milliseconds per commit.
pub fn skipBaseOf(state: *const repostate.RepoState, parent: repostate.Rid) ?repostate.Rid {
    var h: u64 = 0;
    var at = parent;
    while (state.primaryParentOf(at)) |pp| : (h += 1) {
        if (h >= skip_walk_max) return null;
        at = pp;
    }
    var hops = h - logstore.skipBaseHeight(h + 1);
    at = parent;
    while (hops > 0) : (hops -= 1) at = state.primaryParentOf(at) orelse unreachable;
    return at;
}

/// Dialect memory read: has this repository ever stored a delta manifest?
/// Config-absent or unreadable reads as false — the flat default.
pub fn seenDeltaManifest(gpa: std.mem.Allocator, log: *logstore.LogStore) bool {
    var oc = (log.readConfig(gpa) catch return false) orelse return false;
    defer oc.deinit(gpa);
    return oc.config.seen_delta_manifest;
}

/// Record the seen-delta indicator, on the false->true edge only — the same
/// plan-E last-frame-wins append `setSeenSubmanifest` uses, landing in the
/// same `log.commit()` as the delta manifest it is about.
pub fn setSeenDelta(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
    var oc = log.readConfig(gpa) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage,
    };
    var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
    defer if (oc) |*c| c.deinit(gpa);
    if (cfg.seen_delta_manifest) return;
    cfg.seen_delta_manifest = true;
    try log.putConfig(cfg);
}

/// submanifests §8.1 — record the local seen-submanifest indicator, on the
/// false->true edge only. Appends a config frame carrying every existing field
/// plus the flag (plan E last-frame-wins); a repository already carrying it writes
/// nothing. The caller's `log.commit()` makes it durable with the artifacts it
/// describes, so the indicator and the trees it is about land in one fsync.
pub fn setSeenSubmanifest(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
    var oc = log.readConfig(gpa) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        // A config frame that will not decode is not a reason to lose a commit's
        // artifacts; the §7.2 gate arms on the setting alone in that case, and
        // `zit submanifest` shows the operator the unreadable config.
        error.Storage, error.Corrupt => return,
    };
    defer if (oc) |*c| c.deinit(gpa);
    var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
    if (cfg.seen_submanifest) return; // already recorded — no frame per commit
    cfg.seen_submanifest = true;
    try log.putConfig(cfg);
}

/// What a caller keeps open across commits: an append handle on the store's
/// log, the repository view, and the tree editor with its decoded-node cache.
/// The writer lock is taken by each `commit` and held from choosing the
/// parent until the check-in is published, so another writer can land
/// between two commits but never inside one; `commit` reads whatever it
/// appended before choosing a parent. A one-shot caller that does work of
/// its own under the lock (`commit --stdin` resolves settings in a scratch
/// directory beside the store) opens with `hold_lock` instead, and the lock
/// is held from `open` to `deinit`.
///
/// Pinned: `open` initializes in place, and the log and tree store point
/// into the value, so it must not be copied or moved once opened. The
/// directory passed to `open` is borrowed and must stay open with it.
pub const Context = struct {
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    file: std.Io.File,
    fs: storage.FileStorage,
    log: logstore.LogStore,
    /// False only after a failed reopen in `begin`: the log is gone and every
    /// commit refuses until the context is reopened.
    log_open: bool,
    /// The log as this context last saw it under the lock. Null when it could
    /// not be read, which makes the next commit read the log again.
    key: ?logstore.LogStore.LogKey,
    lock: ?storelock.Lock,
    /// The lock is held from `open` to `deinit` rather than per commit.
    hold_lock: bool,
    repo: Repository,
    /// The view predates an append: this context's own publish or another
    /// writer's.
    repo_stale: bool,
    /// False only after a failed reopen of the view, as `log_open`.
    repo_open: bool,
    tree_store: LogTreeStore,
    tree_editor: ?editor.TreeEditor,
    /// The renamed cards (F cards carrying an old-name) of the check-in this
    /// context last recorded, so the next commit on it need not search the
    /// tree for them. Keyed by that check-in's name, so another writer's
    /// appends cannot make it wrong, only unused.
    renamed_memo: ?RenamedMemo,

    const RenamedMemo = struct {
        checkin: Name,
        arena: std.heap.ArenaAllocator,
        cards: []const artifact.FCard,
    };

    /// Open the store under `root_dir` (a store directory or a checkout that
    /// names one): its log for append, repairing a torn tail a crashed writer
    /// left, and the repository view. Takes the writer lock for the open and
    /// releases it before returning, unless `options.hold_lock`.
    pub fn open(self: *Context, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, options: OpenOptions, diag: *Diag) Error!void {
        diag.clear();
        self.* = .{
            .gpa = gpa,
            .io = io,
            .root_dir = root_dir,
            .file = undefined,
            .fs = undefined,
            .log = undefined,
            .log_open = false,
            .key = null,
            .lock = null,
            .hold_lock = options.hold_lock,
            .repo = undefined,
            .repo_stale = false,
            .repo_open = false,
            .tree_store = .{ .gpa = gpa, .log = &self.log },
            .tree_editor = null,
            .renamed_memo = null,
        };
        const held = try self.lockStore(diag);
        self.file = held.file;
        defer if (!options.hold_lock) self.unlockStore();
        errdefer if (options.hold_lock) self.unlockStore();
        errdefer self.file.close(io);
        try self.openLog(diag);
        errdefer self.log.deinit(gpa);
        try self.openView(diag);
    }

    pub const OpenOptions = struct {
        hold_lock: bool = false,
    };

    pub fn deinit(self: *Context) void {
        std.debug.assert(self.hold_lock or self.lock == null);
        self.unlockStore();
        if (self.renamed_memo) |*m| m.arena.deinit();
        if (self.tree_editor) |*t| t.deinit();
        self.tree_store.deinitCache();
        if (self.repo_open) self.repo.deinit(self.gpa);
        if (self.log_open) self.log.deinit(self.gpa);
        self.file.close(self.io);
        self.* = undefined;
    }

    /// Take the writer lock, and with it a fresh handle on the store: the
    /// file at the store's path now, which a rewrite may have replaced since
    /// this context last looked. The caller owns the returned file.
    fn lockStore(self: *Context, diag: *Diag) Error!store_open.StoreAppend {
        std.debug.assert(self.lock == null);
        const held = store_open.openStoreAppend(self.io, self.root_dir) catch |e| {
            if (e == error.StoreBusy) return error.StoreBusy;
            return ioFailure(diag);
        };
        self.lock = held.lock;
        return held;
    }

    /// The end of one commit: the lock goes unless the caller holds it.
    fn end(self: *Context) void {
        if (!self.hold_lock) self.unlockStore();
    }

    fn unlockStore(self: *Context) void {
        if (self.lock) |*l| l.release(self.io);
        self.lock = null;
    }

    fn openLog(self: *Context, diag: *Diag) Error!void {
        std.debug.assert(self.lock != null and !self.log_open);
        const st = self.file.stat(self.io) catch return ioFailure(diag);
        self.fs = storage.FileStorage.init(self.io, self.file, st.size);
        // `hash_kind` 0 is a don't-care on a non-empty file: `open` reads the
        // stored kind from the header.
        self.log = logstore.LogStore.open(self.gpa, self.fs.storageFile(), 0) catch |e| return logOpenError(e, diag);
        self.log_open = true;
        self.key = self.log.logKey() catch null;
    }

    fn openView(self: *Context, diag: *Diag) Error!void {
        std.debug.assert(!self.repo_open);
        self.repo.open(self.gpa, self.io, self.root_dir, .{ .mode = .full }) catch |e| {
            switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                error.CannotRead => diag.set("cannot read repository: input/output error", .{}),
                error.Corrupt => diag.set("cannot read repository: repository is corrupt", .{}),
                error.ImportPending => diag.set("an incremental import is pending; run 'zit import-git --recover DIR'", .{}),
                error.NotARepo => return error.NotARepo,
            }
            return if (e == error.Corrupt) error.Corrupt else error.CannotOpen;
        };
        self.repo_open = true;
        self.repo_stale = false;
    }

    /// Take the lock for one commit and catch up with the store. Unchanged
    /// since this context last held the lock: only its own publish needs
    /// folding into the view (the cache extension path, costing the tail).
    /// Appended to by another writer, or replaced by a rewrite (`pack`,
    /// `shun --force`, which installs a new file): the log is read again
    /// from a fresh handle, and every cache keyed by log offsets is dropped.
    fn begin(self: *Context, diag: *Diag) Error!void {
        if (!self.log_open or !self.repo_open) {
            diag.set("cannot read repository: input/output error", .{});
            return error.CannotOpen;
        }
        // A held lock means no other writer has appended since `open`: only
        // this context's own publishes need folding into the view.
        if (self.hold_lock) return self.refreshView(diag);
        var fresh = (try self.lockStore(diag)).file;
        errdefer self.end();
        var fresh_owned = true;
        defer if (fresh_owned) fresh.close(self.io);
        const st = fresh.stat(self.io) catch return ioFailure(diag);
        var fresh_fs = storage.FileStorage.initReadOnly(self.io, fresh, st.size);
        const unchanged = if (self.key) |k| logstore.LogStore.validateTail(fresh_fs.storageFile(), k) else false;
        if (!unchanged) {
            self.log.deinit(self.gpa);
            self.log_open = false;
            self.file.close(self.io);
            self.file = fresh;
            fresh_owned = false;
            try self.openLog(diag);
            self.tree_store.deinitCache();
            self.tree_store.cache = .{ .memoize_undeltaed = true };
            // A rewrite can drop a node (a shun); nothing decoded before it
            // is trusted after it.
            if (self.tree_editor) |*t| t.rebindStore(self.tree_store.treeStore());
            self.repo_stale = true;
        }
        try self.refreshView(diag);
    }

    fn refreshView(self: *Context, diag: *Diag) Error!void {
        if (!self.repo_stale) return;
        self.repo.deinit(self.gpa);
        self.repo_open = false;
        try self.openView(diag);
    }

    /// The tree editor for partition parameter `k`, made on first use and
    /// remade when the repository's pinned k changes.
    fn treeEditor(self: *Context, k: u4) Error!*editor.TreeEditor {
        if (self.tree_editor) |*t| {
            if (t.format.shape.tree.k == k) return t;
            t.deinit();
            self.tree_editor = null;
        }
        self.tree_editor = editor.TreeEditor.init(self.gpa, self.tree_store.treeStore(), .{
            .hash_algo = .sha3_256,
            .shape = .{ .tree = .{ .k = k, .height_max = @intCast(artifact.max_construct_height) } },
        }, tree_cache_bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => unreachable, // k comes from `Emission.treeK`, in range by construction
        };
        return &self.tree_editor.?;
    }
};

fn logOpenError(e: logstore.OpenError, diag: *Diag) Error {
    switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Storage => {
            diag.set("cannot write the check-in: input/output error", .{});
            return error.Storage;
        },
        error.Corrupt => {
            diag.set("the repository is corrupt", .{});
            return error.Corrupt;
        },
    }
}

fn ioFailure(diag: *Diag) Error {
    diag.set("cannot write the check-in: input/output error", .{});
    return error.Storage;
}

fn putError(e: anyerror, diag: *Diag) Error {
    if (e == error.OutOfMemory) return error.OutOfMemory;
    diag.set("cannot write the check-in: input/output error", .{});
    return error.Storage;
}

fn parentCorrupt(diag: *Diag, why: []const u8) Error {
    diag.set("the parent check-in is corrupt ({s})", .{why});
    return error.Corrupt;
}

fn contentError(e: logstore.ContentError, diag: *Diag) Error {
    switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Storage => {
            diag.set("cannot write the check-in: input/output error", .{});
            return error.Storage;
        },
        error.Corrupt => {
            diag.set("the parent check-in is corrupt", .{});
            return error.Corrupt;
        },
    }
}

/// What `parentChecks` decided.
pub const ParentChecked = struct {
    /// The parent's inherited sym-* tags a `--branch` commit cancels, on `ma`.
    syms: []const []const u8,
    /// The name of the new manifest's skip-delta base (`skipBaseOf`), on `ma`.
    skip_base: ?[]const u8 = null,
};

pub const ParentCheckArgs = struct {
    /// The branch the check-in moves onto, when it leaves its parent's.
    switch_branch: ?[]const u8,
    allow_fork: bool,
};

/// The checks a commit makes against the rest of the repository, not just its
/// own tree: the fork guard, and for a branch switch, that NAME is not an open
/// branch and which of the parent's sym-* tags the new check-in cancels.
/// `branch` is the parent's branch; `parent` the check-in the P card names.
pub fn parentChecks(
    repo: *Repository,
    gpa: std.mem.Allocator,
    ma: std.mem.Allocator,
    args: ParentCheckArgs,
    branch: []const u8,
    parent: []const u8,
    want_skip_base: bool,
    diag: *Diag,
) error{ OutOfMemory, NotTip, BranchExists }!ParentChecked {
    // Fork guard (fossil's commit-time warning): a commit whose
    // parent already has a same-branch child would silently fork the
    // branch. The condition is fossil's — the parent must still be a
    // LEAF — so committing onto the older head of an already-forked
    // branch stays legal (that head is a leaf too). A branch switch is
    // exempt (the check-in leaves the branch, a fork by name), as is
    // `allow_fork` (the deliberate case). No network: this also fires
    // for offline divergence that no autosync could have prevented.
    if (args.switch_branch == null and !args.allow_fork) {
        if (repo.checkinRid(parent)) |prid| {
            if (!repo.state().isLeaf(prid)) {
                const tip = repo.resolve(branch, .checkin) catch null;
                if (tip) |trid| {
                    diag.set(
                        "not the tip of branch '{s}' — check-in {s} is newer.\n" ++
                            "run 'zit update' to join it, or commit with --allow-fork to fork deliberately",
                        .{ branch, repo.hashOf(trid) },
                    );
                } else {
                    diag.set(
                        "the current check-in is no longer the tip of branch '{s}'.\n" ++
                            "run 'zit update' to join the tip, or commit with --allow-fork to fork deliberately",
                        .{branch},
                    );
                }
                return error.NotTip;
            }
        }
    }

    var skip_base: ?[]const u8 = null;
    if (want_skip_base) if (repo.checkinRid(parent)) |prid| if (skipBaseOf(repo.state(), prid)) |brid| {
        skip_base = try ma.dupe(u8, repo.hashOf(brid));
    };

    const bname = args.switch_branch orelse return .{ .syms = &.{}, .skip_base = skip_base };
    const open_leaves = try repo.state().openLeavesOf(gpa, bname);
    defer gpa.free(open_leaves);
    if (open_leaves.len != 0) {
        diag.set("branch '{s}' already exists with an open leaf; commit onto it or choose a new name", .{bname});
        return error.BranchExists;
    }
    // The parent's inherited sym-* tags to cancel, minus the one we
    // re-establish.
    const prid = repo.checkinRid(parent) orelse return .{ .syms = &.{}, .skip_base = skip_base };
    const entries = try repo.state().tagsOf(gpa, prid);
    defer gpa.free(entries);
    const syms = try selectInheritedSyms(ma, entries, bname);
    return .{ .syms = syms, .skip_base = skip_base };
}

/// The parent a commit builds on, as the repository view names it.
const Parent = struct {
    /// Null for the first check-in of an empty repository.
    rid: ?repostate.Rid,
    hash: []const u8,
    /// The branch the parent is on (the main branch for the first check-in).
    branch: []const u8,
    /// The branch the check-in moves onto, when it leaves `branch`.
    switch_branch: ?[]const u8,
};

/// The parent's file set as this commit reads it: a sorted F list, or the S
/// run of a sub-manifest parent edited in place by the tree editor.
const ParentView = union(enum) {
    flat: []const artifact.FCard,
    tree: []const artifact.SCard,
};

/// The parent manifest and what it was resolved from. Owns the parsed
/// manifests; cards in `view` borrow them.
const Base = struct {
    bytes: ?[]u8 = null,
    manifest: ?artifact.Manifest = null,
    normalized: ?[]u8 = null,
    baseline_bytes: ?[]u8 = null,
    baseline: ?artifact.Manifest = null,
    baseline_normalized: ?[]u8 = null,
    rid: ?logstore.Rid = null,
    view: ParentView = .{ .flat = &.{} },

    fn deinit(self: *Base, gpa: std.mem.Allocator) void {
        if (self.baseline) |*m| m.deinit();
        if (self.baseline_normalized) |b| gpa.free(b);
        if (self.baseline_bytes) |b| gpa.free(b);
        if (self.manifest) |*m| m.deinit();
        if (self.normalized) |b| gpa.free(b);
        if (self.bytes) |b| gpa.free(b);
        self.* = undefined;
    }
};

/// One path's new state.
const Change = struct {
    path: []const u8,
    /// The new card, or null when the path leaves the check-in.
    card: ?artifact.FCard,
    /// Content this commit introduces: checked against the policy, stored
    /// when the store lacks it. Null for a delete or unchanged content.
    bytes: ?[]const u8 = null,
    /// The same path's blob in the parent: a delta-base hint.
    base: ?logstore.Rid = null,
};

/// Record a check-in: `req.edits` applied to the parent's file set. Holds the
/// context's lock throughout, so it publishes on the parent it chose.
pub fn commit(ctx: *Context, req: Request, diag: *Diag) Error!Outcome {
    diag.clear();
    try ctx.begin(diag);
    defer ctx.end();
    const gpa = ctx.gpa;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const ma = arena_state.allocator();

    try checkRequest(req, diag);
    const parent = try resolveParent(ctx, ma, req, diag);
    if (parent.rid == null and parent.switch_branch != null) {
        diag.set("--branch needs a parent check-in; the first commit starts branch '{s}'", .{parent.branch});
        return error.BranchOnRoot;
    }

    // The repository's own emission settings, from the frame under the lock.
    var config: ?logstore.OwnedConfig = ctx.log.readConfig(gpa) catch null;
    defer if (config) |*c| c.deinit(gpa);
    const emission: repository.submanifest.Emission = if (config) |c| repository.submanifest.emission(.of(c.config)) else .off;
    const tree_k: ?u4 = emission.treeK();
    const seen_delta = if (config) |c| c.config.seen_delta_manifest else false;

    var base: Base = .{};
    defer base.deinit(gpa);
    try loadBase(ctx, &base, parent.hash, tree_k != null, diag);
    const tree_editor: ?*editor.TreeEditor = if (tree_k) |k| try ctx.treeEditor(k) else null;

    var pending: PendingBlobs = .{};
    defer pending.deinit(gpa);
    const changes = try collectChanges(ctx, ma, &base, req, diag);
    if (changes.len == 0) {
        std.debug.assert(parent.rid != null);
        return .{ .unchanged = .of(parent.hash) };
    }

    var checked: ParentChecked = .{ .syms = &.{} };
    if (parent.rid != null) {
        checked = parentChecks(&ctx.repo, gpa, ma, .{
            .switch_branch = parent.switch_branch,
            .allow_fork = req.allow_fork,
        }, parent.branch, parent.hash, true, diag) catch |e| return e;
    }

    // Policy gates what this commit INTRODUCES, not the tree it inherits;
    // checked before anything is stored, so a refused commit mints nothing.
    const content_policy: ContentPolicy = .{
        .binary_glob = req.policy.binary_glob,
        .crlf_glob = req.policy.crlf_glob,
        .encoding_glob = req.policy.encoding_glob,
        .waived = req.no_checks,
    };
    for (changes) |c| {
        const bytes = c.bytes orelse continue;
        if (content_policy.fault(c.path, bytes)) |f| {
            diag.set("refusing '{s}': it {s}\naccept it for good with 'zit settings {s} \"{s}\"' (a tracked .zit-settings/{s} applies to every clone), or once with --no-checks", .{ c.path, f.describe(), f.glob(), c.path, f.glob() });
            return error.ContentRefused;
        }
    }
    for (changes) |c| {
        const bytes = c.bytes orelse continue;
        var digest: [digest_len]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
        if (ctx.log.lookup(&digest) != null) continue;
        try pending.add(gpa, digest, try gpa.dupe(u8, bytes), c.base);
    }

    // The new file set: S cards from the tree editor, else an F run.
    var staging = StagingStore.init(gpa, ctx.tree_store.treeStore());
    defer staging.deinit();
    var built: ?editor.BuildResult = null;
    defer if (built) |*b| b.deinit();
    var published = false;
    defer if (tree_editor) |t| {
        // Nodes decoded through the staging store name artifacts the log
        // does not hold unless the commit published them.
        if (published) t.rebindContentAddressedStore(ctx.tree_store.treeStore()) else t.rebindStore(ctx.tree_store.treeStore());
    };
    var flat_f: []const artifact.FCard = &.{};
    var tree_s: []const artifact.SCard = &.{};
    // A tree parent is edited in place only by a tree editor (`loadBase`).
    std.debug.assert(base.view == .flat or tree_editor != null);
    if (tree_editor) |t| {
        t.rebindContentAddressedStore(staging.treeStore());
        const commands = try treeCommands(ctx, ma, &base, parent.hash, changes, diag);
        const root: editor.RootView = switch (base.view) {
            .flat => |f| .{ .flat = try stripRenames(ma, f) },
            .tree => |s| .{ .tree = s },
        };
        built = t.apply(root, commands) catch |e| return treeError(e, diag);
        switch (built.?.root) {
            .flat => |f| flat_f = f,
            .tree => |s| tree_s = s,
        }
    } else {
        flat_f = try composeFlat(ma, base.view.flat, changes);
    }

    // Dialect continuation: a flat check-in in a repository carrying delta
    // manifests is itself a delta when that is smaller (`compose.deltaPatch`).
    var delta: ?compose.DeltaPatch = null;
    if (tree_s.len == 0 and base.manifest != null) {
        if (compose.dialectBase(
            &base.manifest.?,
            if (base.baseline) |*b| b else null,
            parent.hash,
            base.manifest.?.b != null or seen_delta,
        )) |dbase| delta = try compose.deltaPatch(ma, flat_f, dbase);
    }

    // submanifests §4.4: the file set is an F run or an S run, never both,
    // and a delta (B card) never sits on a tree.
    std.debug.assert(tree_s.len == 0 or (flat_f.len == 0 and delta == null));
    var r_hex: [32]u8 = undefined;
    const r_card: ?artifact.RCard = if (req.policy.repo_cksum)
        try checksumCard(ctx, ma, &staging, &pending, flat_f, tree_s, &r_hex, diag)
    else
        null;

    var ts_buf: [23]u8 = undefined;
    const ts = artifact.formatWriterTimestampMs(req.now.epoch, req.now.ms, &ts_buf);
    const tags = try artifact.composeCommitTags(ma, .{
        .branch = if (parent.rid == null) parent.branch else parent.switch_branch,
        .close = false,
        .parent_syms = checked.syms,
    });
    const parents = [_]artifact.PCard{.{ .parents = parent.hash, .count = 1 }};
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = if (delta) |d| d.b else null,
        .c = .{ .comment = req.comment },
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = r_card,
        .u = .{ .login = req.user },
        .z = null,
        .w = null,
        .f = if (tree_s.len != 0) &.{} else if (delta) |d| d.f else flat_f,
        .j = &.{},
        .m = &.{},
        .p = if (parent.rid == null) &.{} else &parents,
        .q = &.{},
        .s = tree_s,
        .t = tags,
    };
    defer m.deinit();
    const plain = try artifact.create(&m, gpa);
    defer gpa.free(plain);

    if (req.hooks.before_commit) |hook| hook(req.hooks.ctx) catch return error.HookRefused;
    const sealed: ?[]u8 = if (req.hooks.seal) |seal| seal(req.hooks.ctx, gpa, plain) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Refused => return signFailed(diag),
    } else null;
    defer if (sealed) |s| gpa.free(s);
    if (sealed) |s| if (!std.mem.eql(u8, artifact.stripSignatureWrapper(s), plain)) return signFailed(diag);
    const bytes: []const u8 = sealed orelse plain;
    if (std.debug.runtime_safety) {
        var check = artifact.parse(gpa, bytes) catch |e|
            std.debug.panic("commit produced an unparseable manifest: {s}", .{@errorName(e)});
        defer check.deinit();
        std.debug.assert(check.kind == .manifest);
    }

    var mdigest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{});
    var mname_buf: [64]u8 = undefined;
    const name = Name.of(artifact.artifactName(.sha3_256, bytes, &mname_buf));
    if (ctx.log.lookup(&mdigest)) |held| if (!ctx.log.isPhantom(held)) return .{ .existing = name };

    try publish(ctx, &pending, &staging, tree_s.len != 0, delta != null, &mdigest, bytes, checked.skip_base, base.rid, diag);
    published = true;
    ctx.repo_stale = true;
    ctx.key = ctx.log.logKey() catch null;
    rememberRenames(ctx, name, changes);
    return .{ .recorded = name };
}

fn signFailed(diag: *Diag) Error {
    diag.set("--sign failed: the clearsign command errored or altered the manifest (set ZIT_PGP_COMMAND to override 'gpg --clearsign')", .{});
    return error.SignFailed;
}

fn checkRequest(req: Request, diag: *Diag) Error!void {
    if (req.edits.len == 0) {
        diag.set("nothing to commit (no edits)", .{});
        return error.InvalidRequest;
    }
    if (req.branch) |b| if (b.len == 0) {
        diag.set("--branch name must not be empty", .{});
        return error.InvalidRequest;
    };
    if (std.mem.trim(u8, req.comment, " \t\r\n").len == 0) {
        diag.set("aborting — empty check-in message", .{});
        return error.InvalidRequest;
    }
    if (req.user.len == 0) {
        diag.set("cannot determine the check-in author; pass --user <login>", .{});
        return error.InvalidRequest;
    }
    if (req.now.ms > 999) {
        diag.set("the commit time's milliseconds must be 0..999, not {d}", .{req.now.ms});
        return error.InvalidRequest;
    }
    for (req.edits) |e| switch (e) {
        .put => |p| try checkPath(p.path, diag),
        .delete => |p| try checkPath(p, diag),
        .rename => |r| {
            try checkPath(r.old, diag);
            try checkPath(r.new, diag);
        },
    };
}

fn checkPath(path: []const u8, diag: *Diag) Error!void {
    control_paths.validateWorktreePath(path) catch {
        diag.set("'{s}' is not a path a check-in can hold", .{path});
        return error.InvalidRequest;
    };
}

fn resolveParent(ctx: *Context, ma: std.mem.Allocator, req: Request, diag: *Diag) Error!Parent {
    const repo = &ctx.repo;
    const prid: ?repostate.Rid = if (req.parent) |name| blk: {
        const rid = repo.resolve(name, .checkin) catch |e| {
            switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                error.NotFound => diag.set("check-in '{s}' not found", .{name}),
                error.Ambiguous => diag.set("name '{s}' is ambiguous", .{name}),
            }
            return error.ParentNotFound;
        };
        break :blk repo.checkinRid(repo.hashOf(rid)) orelse {
            diag.set("'{s}' is not a check-in", .{name});
            return error.ParentNotFound;
        };
    } else if (repo.state().newestCheckin() == null) null else blk: {
        // The tip of `branch` when that branch is open, else of the main
        // branch.
        if (req.branch) |b| if (try tipOf(ctx.gpa, repo, b)) |tip| break :blk tip;
        const main_branch = repo.mainBranch();
        if (try tipOf(ctx.gpa, repo, main_branch)) |tip| break :blk tip;
        diag.set("branch '{s}' has no open leaf to build on; name a check-in with --parent", .{main_branch});
        return error.NoOpenLeaf;
    };
    const rid = prid orelse return .{
        .rid = null,
        .hash = "",
        .branch = try ma.dupe(u8, repo.mainBranch()),
        .switch_branch = req.branch,
    };
    const branch = try ma.dupe(u8, repo.branchOf(rid));
    return .{
        .rid = rid,
        .hash = try ma.dupe(u8, repo.hashOf(rid)),
        .branch = branch,
        // A check-in already on B stays there by inheritance; a branch is
        // named only to move it onto B, where commit's rules for starting a
        // branch apply.
        .switch_branch = if (req.branch) |b| (if (std.mem.eql(u8, b, branch)) null else b) else null,
    };
}

/// The newest open leaf of `branch`, or null when it has none. A leaf, not
/// the name's resolution: several check-ins can share one timestamp, and
/// recency alone could then pick one that already has a child.
fn tipOf(gpa: std.mem.Allocator, repo: *Repository, branch: []const u8) error{OutOfMemory}!?repostate.Rid {
    const leaves = try repo.state().openLeavesOf(gpa, branch);
    defer gpa.free(leaves);
    // Newest first, as `openLeavesOf` orders them.
    return if (leaves.len == 0) null else leaves[0];
}

/// Read and parse the parent manifest. A sub-manifest parent stays a tree
/// when the editor will edit it (`edit_trees`), and is flattened otherwise;
/// a delta parent is merged with its baseline (§8.2) into its effective set.
fn loadBase(ctx: *Context, base: *Base, parent_hash: []const u8, edit_trees: bool, diag: *Diag) Error!void {
    if (parent_hash.len == 0) return;
    const gpa = ctx.gpa;
    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexDigest(parent_hash, &digest_buf) orelse return parentCorrupt(diag, "the checkout names it in a form this build cannot decode");
    const rid = ctx.log.lookup(digest) orelse return parentCorrupt(diag, "not present in this repository");
    base.rid = rid;
    base.bytes = (ctx.log.content(gpa, rid) catch |e| return contentError(e, diag)) orelse
        return parentCorrupt(diag, "its content is unreadable");
    // Prior (possibly fossil-origin) history: the legacy F-card tolerance.
    const im = artifact.parseForImport(gpa, base.bytes.?) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return parentCorrupt(diag, @errorName(e)),
    };
    base.manifest = im.manifest;
    base.normalized = im.normalized;
    const pm = &base.manifest.?;
    if (pm.kind != .manifest) return parentCorrupt(diag, "it is not a check-in manifest");
    if (pm.s.len != 0) {
        if (edit_trees) {
            base.view = .{ .tree = pm.s };
            return;
        }
        try flatten(ctx, pm, diag);
        base.view = .{ .flat = pm.flattened.? };
        return;
    }
    const b = pm.b orelse {
        base.view = .{ .flat = pm.f };
        return;
    };
    // §8.2/§8.3: a delta parent's own F run is a PATCH, not a file set.
    var bdigest_buf: [digest_len]u8 = undefined;
    const bdigest = hexDigest(b.hash, &bdigest_buf) orelse return parentCorrupt(diag, "its baseline is named in a form this build cannot decode");
    const brid = ctx.log.lookup(bdigest) orelse return parentCorrupt(diag, "its baseline is not present in this repository");
    base.baseline_bytes = (ctx.log.content(gpa, brid) catch |e| return contentError(e, diag)) orelse
        return parentCorrupt(diag, "its baseline content is unreadable");
    const bim = artifact.parseForImport(gpa, base.baseline_bytes.?) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return parentCorrupt(diag, @errorName(e)),
    };
    base.baseline = bim.manifest;
    base.baseline_normalized = bim.normalized;
    const bm = &base.baseline.?;
    // A baseline that is itself a delta is merged one level, as Fossil does
    // (§4.7.3); `compose.dialectBase` then keeps the check-in flat.
    if (bm.kind != .manifest) return parentCorrupt(diag, "its baseline is not a manifest");
    if (bm.s.len != 0) try flatten(ctx, bm, diag);
    const BaselineCtx = struct {
        bm: *const artifact.Manifest,
        fn resolve(c: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
            const self_ctx: *@This() = @ptrCast(@alignCast(c));
            _ = hash; // one candidate, already matched to the B card above
            return repository.manifestFilesOf(self_ctx.bm);
        }
    };
    var bctx = BaselineCtx{ .bm = bm };
    var walk = artifact.enumerate(repository.manifestFilesOf(pm), .{
        .ctx = @ptrCast(&bctx),
        .resolveFn = BaselineCtx.resolve,
    }) catch return parentCorrupt(diag, "its baseline does not enumerate");
    var effective: std.ArrayList(artifact.FCard) = .empty;
    // The cards borrow the manifests; the list lives on the parent's arena
    // so it is freed with them.
    const owner = pm.arena_state.allocator();
    while (walk.next()) |fc| {
        if (fc.hash == null) continue; // merged delete markers carry no file
        try effective.append(owner, fc);
    }
    base.view = .{ .flat = effective.items };
}

fn flatten(ctx: *Context, m: *artifact.Manifest, diag: *Diag) Error!void {
    var missing = false;
    Repository.flattenTreeFromLogDiagnose(ctx.gpa, &ctx.log, m, &missing) catch |e| {
        if (missing) {
            diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
            return error.MissingNode;
        }
        if (e == error.OutOfMemory) return error.OutOfMemory;
        return parentCorrupt(diag, @errorName(e));
    };
}

fn hexDigest(hex: []const u8, out: *[digest_len]u8) ?[]const u8 {
    if (hex.len != 40 and hex.len != digest_len * 2) return null;
    const n = hex.len / 2;
    _ = std.fmt.hexToBytes(out[0..n], hex) catch return null;
    return out[0..n];
}

/// Each edit as the path state it leaves, validated against the parent: the
/// same shape rules, unchanged-content rule and mode rule the working-tree
/// commit applies to a captured file. Empty when every edit matches the
/// parent.
fn collectChanges(
    ctx: *Context,
    ma: std.mem.Allocator,
    base: *const Base,
    req: Request,
    diag: *Diag,
) Error![]const Change {
    var changes: std.ArrayList(Change) = .empty;
    var touched: std.StringHashMapUnmanaged(void) = .empty;
    var removed: std.StringHashMapUnmanaged(void) = .empty;
    // Paths this commit adds to the tree: their shape is checked once every
    // edit is known, since a later edit can collide with an earlier one.
    var added: std.ArrayList([]const u8) = .empty;
    for (req.edits) |e| switch (e) {
        .put => |p| {
            try touch(&touched, ma, p.path, diag);
            if (p.bytes.len > max_file_bytes) return tooLarge(diag, p.path, p.bytes.len);
            const current = try lookupParent(ctx, ma, base, p.path, diag);
            if (current) |c| if (c.mode == .symlink) return notRegular(diag, p.path);
            const mode = p.mode orelse if (current) |c| c.mode else .regular;
            if (mode == .symlink) return notRegular(diag, p.path);
            if (try putChange(ctx, ma, p.path, p.bytes, mode, current)) |change| try changes.append(ma, change);
            if (current == null) try added.append(ma, p.path);
        },
        .delete => |path| {
            try touch(&touched, ma, path, diag);
            _ = (try lookupParent(ctx, ma, base, path, diag)) orelse return absent(diag, path);
            try changes.append(ma, .{ .path = path, .card = null });
            try removed.put(ma, path, {});
        },
        .rename => |r| {
            try touch(&touched, ma, r.old, diag);
            try touch(&touched, ma, r.new, diag);
            const source = (try lookupParent(ctx, ma, base, r.old, diag)) orelse return absent(diag, r.old);
            if (source.mode == .symlink) return notRegular(diag, r.old);
            if (try lookupParent(ctx, ma, base, r.new, diag) != null) {
                diag.set("'{s}' is already in the parent, so '{s}' cannot be renamed onto it; nothing was written", .{ r.new, r.old });
                return error.PathConflict;
            }
            const mode = r.mode orelse source.mode;
            if (mode == .symlink) return notRegular(diag, r.new);
            const bytes = r.bytes orelse try blobBytes(ctx, ma, source.hash.?, diag);
            if (bytes.len > max_file_bytes) return tooLarge(diag, r.new, bytes.len);
            // The working-tree commit captures NEW as a file the parent does
            // not have: always freshly named, always policy-checked, no delta
            // hint. The old name rides as the fourth token, which the grammar
            // keeps only after a perm token, so a regular file carries `w`.
            var name_buf: [64]u8 = undefined;
            const name = try ma.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));
            try changes.append(ma, .{
                .path = r.new,
                .card = .{
                    .filename = r.new,
                    .hash = name,
                    .perm = if (mode == .executable) "x" else "w",
                    .old_name = r.old,
                    .mode = mode,
                },
                .bytes = bytes,
            });
            try changes.append(ma, .{ .path = r.old, .card = null });
            try removed.put(ma, r.old, {});
            try added.append(ma, r.new);
        },
    };
    for (added.items) |path| try checkShape(ctx, ma, base, path, &touched, &removed, added.items, diag);
    return changes.items;
}

/// The change a put makes, or null when it changes nothing. "Unchanged" is
/// decided by the ALGORITHM OF THE PARENT'S NAME, as Fossil decides it: a
/// SHA-1 parent's unchanged file keeps its SHA-1 name, and only new content
/// takes the SHA3 name.
fn putChange(
    ctx: *Context,
    ma: std.mem.Allocator,
    path: []const u8,
    bytes: []const u8,
    mode: artifact.FileMode,
    current: ?artifact.FCard,
) Error!?Change {
    var name_buf: [64]u8 = undefined;
    var same_content = false;
    var base_rid: ?logstore.Rid = null;
    if (current) |c| {
        const have = c.hash.?;
        const algo: artifact.HashAlgo = if (have.len == 40) .sha1 else .sha3_256;
        same_content = std.mem.eql(u8, artifact.artifactName(algo, bytes, &name_buf), have);
        if (same_content and mode == c.mode) return null;
        var digest_buf: [digest_len]u8 = undefined;
        if (hexDigest(have, &digest_buf)) |d| base_rid = ctx.log.lookup(d);
    }
    const name = if (same_content) current.?.hash.? else try ma.dupe(u8, artifact.artifactName(.sha3_256, bytes, &name_buf));
    return .{
        .path = path,
        .card = .{
            .filename = path,
            .hash = name,
            .perm = if (mode == .executable) "x" else null,
            .old_name = null,
            .mode = mode,
        },
        .bytes = if (same_content) null else bytes,
        .base = base_rid,
    };
}

fn touch(touched: *std.StringHashMapUnmanaged(void), ma: std.mem.Allocator, path: []const u8, diag: *Diag) Error!void {
    const got = try touched.getOrPut(ma, path);
    if (got.found_existing) {
        diag.set("'{s}' is named by more than one edit", .{path});
        return error.InvalidRequest;
    }
}

fn tooLarge(diag: *Diag, path: []const u8, size: usize) Error {
    diag.set("cannot commit '{s}': {d} bytes exceeds the {d}-byte limit on one file", .{ path, size, max_file_bytes });
    return error.TooLarge;
}

fn notRegular(diag: *Diag, path: []const u8) Error {
    diag.set("'{s}' is a symbolic link in the parent; --stdin records regular files only", .{path});
    return error.PathConflict;
}

fn absent(diag: *Diag, path: []const u8) Error {
    diag.set("'{s}' is not in the parent; nothing was written", .{path});
    return error.PathConflict;
}

/// PATH must fit the new tree as a file: no proper ancestor of it a file, and
/// it not a directory. Either would mint a manifest holding both `d/f` and
/// `d/f/x`, which no checkout can materialize.
fn checkShape(
    ctx: *Context,
    ma: std.mem.Allocator,
    base: *const Base,
    path: []const u8,
    touched: *const std.StringHashMapUnmanaged(void),
    removed: *const std.StringHashMapUnmanaged(void),
    added: []const []const u8,
    diag: *Diag,
) Error!void {
    var i: usize = 0;
    while (std.mem.indexOfScalarPos(u8, path, i, '/')) |slash| : (i = slash + 1) {
        const above = path[0..slash];
        const in_parent = !removed.contains(above) and (try lookupParent(ctx, ma, base, above, diag)) != null;
        if (in_parent or (touched.contains(above) and !removed.contains(above))) {
            diag.set("'{s}' is a file in the parent, so '{s}' cannot be one under it; nothing was written", .{ above, path });
            return error.PathConflict;
        }
    }
    const dir_prefix = try std.mem.concat(ma, u8, &.{ path, "/" });
    for (added) |other| if (std.mem.startsWith(u8, other, dir_prefix)) {
        diag.set("'{s}' is a directory in the parent (it holds '{s}'); nothing was written", .{ path, other });
        return error.PathConflict;
    };
    if (try firstUnder(ctx, ma, base, dir_prefix, removed, diag)) |inside| {
        diag.set("'{s}' is a directory in the parent (it holds '{s}'); nothing was written", .{ path, inside });
        return error.PathConflict;
    }
}

/// The parent's card for `path`, copied onto `ma`.
fn lookupParent(ctx: *Context, ma: std.mem.Allocator, base: *const Base, path: []const u8, diag: *Diag) Error!?artifact.FCard {
    switch (base.view) {
        .flat => |f| {
            const at = lowerBound(f, path);
            if (at < f.len and std.mem.eql(u8, f[at].filename, path)) return f[at];
            return null;
        },
        .tree => |s| return lookupRoute(ctx, ma, s, path, 0, diag),
    }
}

/// `path`'s card in the tree under `cards`, reading only the nodes on its
/// route: one per level. `TreeEditor.lookup` would answer the same, but on
/// an editor that has not yet seen this root it first validates every node
/// the root reaches (55 ms of a 42k-file tree); the editor's own build
/// validates each node it reads, so the commit loses no check.
fn lookupRoute(
    ctx: *Context,
    ma: std.mem.Allocator,
    cards: []const artifact.SCard,
    path: []const u8,
    depth: usize,
    diag: *Diag,
) Error!?artifact.FCard {
    if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
    var node = try loadNode(ctx, cards[childIndex(cards, path)].hash, diag);
    defer node.deinit(ctx.gpa);
    const m = &node.manifest;
    if (m.s.len != 0) return lookupRoute(ctx, ma, m.s, path, depth + 1, diag);
    const at = lowerBound(m.f, path);
    if (at < m.f.len and std.mem.eql(u8, m.f[at].filename, path)) return try cloneCard(ma, m.f[at]);
    return null;
}

/// The first parent path under `prefix` that this commit does not remove.
fn firstUnder(
    ctx: *Context,
    ma: std.mem.Allocator,
    base: *const Base,
    prefix: []const u8,
    removed: *const std.StringHashMapUnmanaged(void),
    diag: *Diag,
) Error!?[]const u8 {
    switch (base.view) {
        .flat => |f| {
            var at = lowerBound(f, prefix);
            while (at < f.len and std.mem.startsWith(u8, f[at].filename, prefix)) : (at += 1) {
                if (!removed.contains(f[at].filename)) return f[at].filename;
            }
            return null;
        },
        .tree => |s| {
            var target = prefix;
            // Each round steps past one removed path, so the removals bound it.
            var rounds: usize = 0;
            while (rounds <= removed.count()) : (rounds += 1) {
                const found = (try firstAtOrAfter(ctx, ma, s, target, 0, diag)) orelse return null;
                if (!std.mem.startsWith(u8, found, prefix)) return null;
                if (!removed.contains(found)) return found;
                // NUL sorts below every byte a path can hold, so this is the
                // least name after `found`.
                target = try std.mem.concat(ma, u8, &.{ found, "\x00" });
            }
            unreachable;
        },
    }
}

/// The least path at or after `target` in the tree under `cards`, by a seek
/// along S-card ranges: one node per level, plus at most one right sibling
/// when the target lies past a node's last entry.
fn firstAtOrAfter(
    ctx: *Context,
    ma: std.mem.Allocator,
    cards: []const artifact.SCard,
    target: []const u8,
    depth: usize,
    diag: *Diag,
) Error!?[]const u8 {
    if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
    var j = childIndex(cards, target);
    while (j < cards.len) : (j += 1) {
        var node = try loadNode(ctx, cards[j].hash, diag);
        defer node.deinit(ctx.gpa);
        const m = &node.manifest;
        if (m.s.len != 0) {
            if (try firstAtOrAfter(ctx, ma, m.s, target, depth + 1, diag)) |found| return found;
        } else {
            const at = lowerBound(m.f, target);
            if (at < m.f.len) return try ma.dupe(u8, m.f[at].filename);
        }
    }
    return null;
}

const LoadedNode = struct {
    bytes: editor.LoadedBytes,
    manifest: artifact.Manifest,

    fn deinit(self: *LoadedNode, gpa: std.mem.Allocator) void {
        _ = gpa;
        self.manifest.deinit();
        self.bytes.deinit();
    }
};

fn loadNode(ctx: *Context, name: []const u8, diag: *Diag) Error!LoadedNode {
    return loadNodeFrom(ctx.gpa, ctx.tree_store.treeStore(), name, diag);
}

fn loadNodeFrom(gpa: std.mem.Allocator, store: editor.TreeStore, name: []const u8, diag: *Diag) Error!LoadedNode {
    var bytes = store.load_fn(store.ctx, gpa, name) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.MissingNode => {
            diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
            return error.MissingNode;
        },
        error.StorageFailure => {
            diag.set("cannot write the check-in: input/output error", .{});
            return error.Storage;
        },
    };
    errdefer bytes.deinit();
    var name_buf: [64]u8 = undefined;
    const algo: artifact.HashAlgo = if (name.len == 40) .sha1 else .sha3_256;
    if (!std.mem.eql(u8, artifact.artifactName(algo, bytes.bytes, &name_buf), name)) return parentCorrupt(diag, "a sub-manifest does not hash to its name");
    const manifest = artifact.parse(gpa, bytes.bytes) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return parentCorrupt(diag, @errorName(e)),
    };
    if (manifest.kind != .submanifest) {
        var m = manifest;
        m.deinit();
        return parentCorrupt(diag, "a sub-manifest it names is not one");
    }
    return .{ .bytes = bytes, .manifest = manifest };
}

/// Index of the S card whose range holds `target`: the last card whose first
/// path is not past it, or the first card when the target precedes them all.
fn childIndex(cards: []const artifact.SCard, target: []const u8) usize {
    std.debug.assert(cards.len != 0);
    var low: usize = 0;
    var high = cards.len;
    while (low < high) {
        const mid = low + (high - low) / 2;
        if (artifact.compareBytes(cards[mid].filename, target) != .gt) low = mid + 1 else high = mid;
    }
    return if (low == 0) 0 else low - 1;
}

fn lowerBound(entries: []const artifact.FCard, path: []const u8) usize {
    var low: usize = 0;
    var high = entries.len;
    while (low < high) {
        const mid = low + (high - low) / 2;
        if (artifact.compareBytes(entries[mid].filename, path) == .lt) low = mid + 1 else high = mid;
    }
    return low;
}

fn cloneCard(ma: std.mem.Allocator, c: artifact.FCard) error{OutOfMemory}!artifact.FCard {
    return .{
        .filename = try ma.dupe(u8, c.filename),
        .hash = if (c.hash) |h| try ma.dupe(u8, h) else null,
        .perm = if (c.perm) |p| try ma.dupe(u8, p) else null,
        .old_name = if (c.old_name) |o| try ma.dupe(u8, o) else null,
        .mode = c.mode,
    };
}

fn blobBytes(ctx: *Context, ma: std.mem.Allocator, hash: []const u8, diag: *Diag) Error![]const u8 {
    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexDigest(hash, &digest_buf) orelse return parentCorrupt(diag, "a parent F card names a blob in an undecodable form");
    const rid = ctx.log.lookup(digest) orelse {
        diag.set("a renamed file's content is elided by this store's cone; it is not here to record", .{});
        return error.ElidedByCone;
    };
    const bytes = (ctx.log.content(ma, rid) catch |e| return contentError(e, diag)) orelse
        return parentCorrupt(diag, "a parent F card's blob is unreadable");
    return bytes;
}

/// A carried card loses its one-shot rename provenance (§4.7.6), exactly as
/// `compose.composeFCards` drops it, along with the `w` placeholder a regular
/// file carried only to keep the old-name token in its field.
fn stripped(c: artifact.FCard) artifact.FCard {
    var carried = c;
    carried.old_name = null;
    if (carried.mode == .regular and carried.perm != null and std.mem.eql(u8, carried.perm.?, "w")) carried.perm = null;
    return carried;
}

fn stripRenames(ma: std.mem.Allocator, f: []const artifact.FCard) error{OutOfMemory}![]const artifact.FCard {
    for (f) |c| {
        if (c.old_name != null) break;
    } else return f;
    const out = try ma.alloc(artifact.FCard, f.len);
    for (f, out) |c, *o| o.* = if (c.old_name != null) stripped(c) else c;
    return out;
}

/// A flat check-in's F run: the parent's cards minus what this commit removes,
/// plus its new cards, through the same composition the working-tree commit
/// uses.
fn composeFlat(ma: std.mem.Allocator, parent: []const artifact.FCard, changes: []const Change) error{OutOfMemory}![]const artifact.FCard {
    var removed: std.StringHashMapUnmanaged(void) = .empty;
    var captured: std.ArrayList(artifact.FCard) = .empty;
    for (changes) |c| {
        if (c.card) |card| try captured.append(ma, card) else try removed.put(ma, c.path, {});
    }
    var kept: std.ArrayList(artifact.FCard) = .empty;
    try kept.ensureTotalCapacity(ma, parent.len);
    for (parent) |p| if (!removed.contains(p.filename)) kept.appendAssumeCapacity(p);
    const composed = try compose.composeFCards(ma, .{
        .parent = kept.items,
        .captured = captured.items,
        .policy = .carry_forward,
    });
    return composed.f;
}

/// The editor commands for `changes`, plus one per parent card that still
/// carries rename provenance and that no change replaces: the carried card
/// sheds it, as `stripped` describes.
fn treeCommands(
    ctx: *Context,
    ma: std.mem.Allocator,
    base: *const Base,
    parent_hash: []const u8,
    changes: []const Change,
    diag: *Diag,
) Error![]const editor.TreeCommand {
    var commands: std.ArrayList(editor.TreeCommand) = .empty;
    var touched: std.StringHashMapUnmanaged(void) = .empty;
    for (changes) |c| {
        try touched.put(ma, c.path, {});
        try commands.append(ma, if (c.card) |card| .{ .upsert = card } else .{ .delete = c.path });
    }
    switch (base.view) {
        .flat => {}, // `stripRenames` sheds it from the flat root itself
        .tree => |s| for (try parentRenames(ctx, ma, s, parent_hash, diag)) |card| {
            if (touched.contains(card.filename)) continue;
            try commands.append(ma, .{ .upsert = stripped(card) });
        },
    }
    return commands.items;
}

/// The parent tree's cards that carry an old-name. Known without a walk when
/// this context recorded the parent; otherwise found by reading every node
/// (the leaves are only scanned for a fourth token, never parsed unless one
/// appears).
fn parentRenames(ctx: *Context, ma: std.mem.Allocator, cards: []const artifact.SCard, parent_hash: []const u8, diag: *Diag) Error![]const artifact.FCard {
    if (ctx.renamed_memo) |*memo| if (std.mem.eql(u8, memo.checkin.slice(), parent_hash)) return memo.cards;
    var found: std.ArrayList(artifact.FCard) = .empty;
    try scanRenames(ctx, ma, cards, 0, &found, diag);
    return found.items;
}

fn scanRenames(
    ctx: *Context,
    ma: std.mem.Allocator,
    cards: []const artifact.SCard,
    depth: usize,
    found: *std.ArrayList(artifact.FCard),
    diag: *Diag,
) Error!void {
    if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
    const store = ctx.tree_store.treeStore();
    for (cards) |card| {
        var bytes = store.load_fn(store.ctx, ctx.gpa, card.hash) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            error.MissingNode => {
                diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
                return error.MissingNode;
            },
            error.StorageFailure => {
                diag.set("cannot write the check-in: input/output error", .{});
                return error.Storage;
            },
        };
        defer bytes.deinit();
        if (!isInterior(bytes.bytes) and !hasRenamedCard(bytes.bytes)) continue;
        var m = artifact.parse(ctx.gpa, bytes.bytes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return parentCorrupt(diag, @errorName(e)),
        };
        defer m.deinit();
        if (m.s.len != 0) {
            try scanRenames(ctx, ma, m.s, depth + 1, found, diag);
            continue;
        }
        for (m.f) |f| if (f.old_name != null) try found.append(ma, try cloneCard(ma, f));
    }
}

fn isInterior(bytes: []const u8) bool {
    return std.mem.startsWith(u8, bytes, "S ") or std.mem.indexOf(u8, bytes, "\nS ") != null;
}

/// Whether some F line holds a fourth token. Filenames are §4.3-escaped, so a
/// space separates tokens and nothing else: four spaces mean name, hash,
/// perm and old-name.
fn hasRenamedCard(bytes: []const u8) bool {
    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, "F ")) continue;
        if (std.mem.count(u8, line, " ") >= 4) return true;
    }
    return false;
}

fn rememberRenames(ctx: *Context, checkin: Name, changes: []const Change) void {
    if (ctx.renamed_memo) |*m| m.arena.deinit();
    ctx.renamed_memo = null;
    var arena = std.heap.ArenaAllocator.init(ctx.gpa);
    var cards: std.ArrayList(artifact.FCard) = .empty;
    for (changes) |c| {
        const card = c.card orelse continue;
        if (card.old_name == null) continue;
        const copy = cloneCard(arena.allocator(), card) catch {
            arena.deinit();
            return;
        };
        cards.append(arena.allocator(), copy) catch {
            arena.deinit();
            return;
        };
    }
    ctx.renamed_memo = .{ .checkin = checkin, .arena = arena, .cards = cards.items };
}

fn treeError(e: editor.Error, diag: *Diag) Error {
    switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.MissingNode => {
            diag.set("the parent check-in is corrupt (a required sub-manifest is missing)", .{});
            return error.MissingNode;
        },
        error.StorageFailure => {
            diag.set("cannot write the check-in: input/output error", .{});
            return error.Storage;
        },
        else => return parentCorrupt(diag, @errorName(e)),
    }
}

/// The R card (§6.3) over the new check-in's complete file list, in order:
/// the F run, or the walk of the new tree (§5.5). Content comes from the
/// store keyed by each card's hash, never from the edits a second time.
/// Over a sparse store an elided carried blob makes the aggregate
/// uncomputable, and R is omitted rather than fabricated (sparse-cone RFC
/// section 4.3).
fn checksumCard(
    ctx: *Context,
    ma: std.mem.Allocator,
    staging: *StagingStore,
    pending: *const PendingBlobs,
    flat_f: []const artifact.FCard,
    tree_s: []const artifact.SCard,
    out: *[32]u8,
    diag: *Diag,
) Error!?artifact.RCard {
    var entries: std.ArrayList(artifact.FCard) = .empty;
    if (tree_s.len != 0) try treeCards(ctx.gpa, ma, staging.treeStore(), tree_s, 0, &entries, diag) else try entries.appendSlice(ma, flat_f);
    var agg: artifact.ChecksumAggregator = .{};
    for (entries.items) |f| {
        const fhash = f.hash orelse return parentCorrupt(diag, "a parent F card carries no hash");
        var fdigest_buf: [digest_len]u8 = undefined;
        const fdigest = hexDigest(fhash, &fdigest_buf) orelse return parentCorrupt(diag, "a parent F card names a blob in an undecodable form");
        if (pending.get(fdigest)) |held| {
            agg.update(f.filename, held);
            continue;
        }
        const frid = ctx.log.lookup(fdigest) orelse {
            if (repocache.readStoreCone(ctx.gpa, ctx.io, ctx.repo.root_dir)) |c| {
                var cone = c;
                defer cone.deinit(ctx.gpa);
                if (!cone.covers(f.filename)) return null;
            }
            return parentCorrupt(diag, "a parent F card names a blob this repository lacks");
        };
        const fbytes = (ctx.log.content(ctx.gpa, frid) catch |e| return contentError(e, diag)) orelse
            return parentCorrupt(diag, "a parent F card's blob is unreadable");
        defer ctx.gpa.free(fbytes);
        agg.update(f.filename, fbytes);
    }
    return .{ .checksum = agg.final(out) };
}

/// Every F card of the tree under `cards`, in walk order, copied onto `ma`.
fn treeCards(
    gpa: std.mem.Allocator,
    ma: std.mem.Allocator,
    store: editor.TreeStore,
    cards: []const artifact.SCard,
    depth: usize,
    out: *std.ArrayList(artifact.FCard),
    diag: *Diag,
) Error!void {
    if (depth >= artifact.max_construct_height) return parentCorrupt(diag, "its sub-manifest tree is too deep");
    for (cards) |card| {
        var node = try loadNodeFrom(gpa, store, card.hash, diag);
        defer node.deinit(gpa);
        if (node.manifest.s.len != 0) {
            try treeCards(gpa, ma, store, node.manifest.s, depth + 1, out, diag);
        } else for (node.manifest.f) |f| try out.append(ma, try cloneCard(ma, f));
    }
}

/// Append everything the check-in needs, the manifest last, and make it
/// durable in one fsync: blobs, then sub-manifests (stored before the
/// manifest that references them, submanifests §6.2), then the indicators
/// that describe them, then the manifest delta-encoded against its skip base.
fn publish(
    ctx: *Context,
    pending: *const PendingBlobs,
    staging: *const StagingStore,
    is_tree: bool,
    is_delta: bool,
    mdigest: *const [digest_len]u8,
    bytes: []const u8,
    skip_base: ?[]const u8,
    parent_rid: ?logstore.Rid,
    diag: *Diag,
) Error!void {
    const gpa = ctx.gpa;
    for (pending.items.items) |it| {
        _ = ctx.log.putArtifact(&it.digest, it.bytes, it.base) catch |e| return putError(e, diag);
    }
    if (staging.nodes.items.len != 0) {
        const nodes = try gpa.alloc(editor.StoreBatchNode, staging.nodes.items.len);
        defer gpa.free(nodes);
        for (staging.nodes.items, nodes) |n, *out| out.* = .{ .name = n.name, .bytes = n.bytes, .f_cards = n.f_cards, .base = n.base };
        const store = ctx.tree_store.treeStore();
        store.put_batch_fn(store.ctx, nodes) catch |e| return putError(e, diag);
    }
    // submanifests §8.1: the seen-submanifest indicator arms the §7.2
    // transfer gate even after the setting is disabled again.
    if (is_tree) setSeenSubmanifest(gpa, &ctx.log) catch |e| return putError(e, diag);
    if (is_delta) setSeenDelta(gpa, &ctx.log) catch |e| return putError(e, diag);
    const skip_base_rid: ?logstore.Rid = if (skip_base) |name| blk: {
        var sdigest_buf: [digest_len]u8 = undefined;
        const sdigest = hexDigest(name, &sdigest_buf) orelse break :blk null;
        break :blk ctx.log.lookup(sdigest);
    } else null;
    _ = ctx.log.putArtifactSkipBase(mdigest, bytes, skip_base_rid, parent_rid) catch |e| return putError(e, diag);
    ctx.log.commit() catch |e| return putError(e, diag);
}

/// The tree editor's writes for one commit, held until the commit publishes:
/// the before-commit hook and the signer run after the tree is built, and a
/// refusal from either must leave the store as it was. Reads fall through to
/// the log for every node not built here.
const StagingStore = struct {
    gpa: std.mem.Allocator,
    base: editor.TreeStore,
    nodes: std.ArrayList(Node) = .empty,
    index: std.StringHashMapUnmanaged(usize) = .empty,

    const Node = struct {
        name: []u8,
        bytes: []u8,
        f_cards: usize,
        base: ?[]u8,
    };

    fn init(gpa: std.mem.Allocator, base: editor.TreeStore) StagingStore {
        return .{ .gpa = gpa, .base = base };
    }

    fn deinit(self: *StagingStore) void {
        self.index.deinit(self.gpa);
        for (self.nodes.items) |n| {
            self.gpa.free(n.name);
            self.gpa.free(n.bytes);
            if (n.base) |b| self.gpa.free(b);
        }
        self.nodes.deinit(self.gpa);
        self.* = undefined;
    }

    fn treeStore(self: *StagingStore) editor.TreeStore {
        return .{ .ctx = self, .load_fn = load, .put_fn = put, .put_batch_fn = putBatch };
    }

    fn load(ctx: *anyopaque, allocator: std.mem.Allocator, name: []const u8) editor.StoreError!editor.LoadedBytes {
        const self: *StagingStore = @ptrCast(@alignCast(ctx));
        if (self.index.get(name)) |at| {
            const bytes = allocator.dupe(u8, self.nodes.items[at].bytes) catch return error.OutOfMemory;
            return .{ .bytes = bytes, .allocator = allocator };
        }
        return self.base.load_fn(self.base.ctx, allocator, name);
    }

    fn put(ctx: *anyopaque, name: []const u8, bytes: []const u8) editor.StoreError!void {
        const self: *StagingStore = @ptrCast(@alignCast(ctx));
        return self.stage(.{ .name = name, .bytes = bytes, .f_cards = 0 });
    }

    fn putBatch(ctx: *anyopaque, nodes: []const editor.StoreBatchNode) editor.StoreError!void {
        const self: *StagingStore = @ptrCast(@alignCast(ctx));
        for (nodes) |node| try self.stage(node);
    }

    /// Names are checked against their bytes where they are written, by the
    /// log's store (`LogTreeStore`), not here.
    fn stage(self: *StagingStore, node: editor.StoreBatchNode) editor.StoreError!void {
        if (self.index.get(node.name)) |at| {
            if (!std.mem.eql(u8, self.nodes.items[at].bytes, node.bytes)) return error.StorageFailure;
            return;
        }
        try self.nodes.ensureUnusedCapacity(self.gpa, 1);
        try self.index.ensureUnusedCapacity(self.gpa, 1);
        const name = try self.gpa.dupe(u8, node.name);
        errdefer self.gpa.free(name);
        const bytes = try self.gpa.dupe(u8, node.bytes);
        errdefer self.gpa.free(bytes);
        const base = if (node.base) |b| try self.gpa.dupe(u8, b) else null;
        self.index.putAssumeCapacityNoClobber(name, self.nodes.items.len);
        self.nodes.appendAssumeCapacity(.{ .name = name, .bytes = bytes, .f_cards = node.f_cards, .base = base });
    }
};

test "hasRenamedCard: four spaces on an F line mean an old-name token, escaped names never add one" {
    try std.testing.expect(!hasRenamedCard("F a\\sb.txt 0123 x\nZ 00\n"));
    try std.testing.expect(hasRenamedCard("F new.txt 0123 w old.txt\nZ 00\n"));
    try std.testing.expect(!hasRenamedCard("C a\\sb\\sc\\sd\\se\nF a 1\n"));
}

test "composeFlat: a delete and a rename leave the parent; the rename keeps its provenance, a carried one sheds it" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const ma = arena_state.allocator();
    const parent = [_]artifact.FCard{
        .{ .filename = "a", .hash = "1", .perm = null, .old_name = null, .mode = .regular },
        .{ .filename = "b", .hash = "2", .perm = "w", .old_name = "z", .mode = .regular },
        .{ .filename = "c", .hash = "3", .perm = null, .old_name = null, .mode = .regular },
    };
    const changes = [_]Change{
        .{ .path = "a", .card = null },
        .{ .path = "d", .card = .{ .filename = "d", .hash = "3", .perm = "w", .old_name = "c", .mode = .regular } },
        .{ .path = "c", .card = null },
    };
    const f = try composeFlat(ma, &parent, &changes);
    try std.testing.expectEqual(@as(usize, 2), f.len);
    try std.testing.expectEqualStrings("b", f[0].filename);
    try std.testing.expect(f[0].old_name == null and f[0].perm == null);
    try std.testing.expectEqualStrings("d", f[1].filename);
    try std.testing.expectEqualStrings("c", f[1].old_name.?);
    try std.testing.expectEqualStrings("w", f[1].perm.?);
}

test "childIndex: the S card whose range holds the target, or the first" {
    const cards = [_]artifact.SCard{
        .{ .filename = "b", .hash = "1" },
        .{ .filename = "m", .hash = "2" },
        .{ .filename = "t", .hash = "3" },
    };
    try std.testing.expectEqual(@as(usize, 0), childIndex(&cards, "a"));
    try std.testing.expectEqual(@as(usize, 0), childIndex(&cards, "b"));
    try std.testing.expectEqual(@as(usize, 1), childIndex(&cards, "m/x"));
    try std.testing.expectEqual(@as(usize, 2), childIndex(&cards, "zz"));
}

test "Diag: a message longer than the buffer is cut, never overflowed" {
    var diag: Diag = .{};
    const long: [Diag.diag_max + 100]u8 = @splat('x');
    diag.set("{s}", .{&long});
    try std.testing.expectEqual(@as(usize, Diag.diag_max), diag.text().len);
    diag.clear();
    try std.testing.expectEqualStrings("", diag.text());
}

test "commit repo-state §6.2: only inherited (propagating) parent syms are cancelled" {
    const gpa = std.testing.allocator;
    const TagEntry = repostate.RepoState.TagEntry;
    // A parent (as `tagsOf` surfaces it: active rows only) carrying every case:
    // an inherited propagating sym (MUST cancel), the branch's own propagating
    // sym (excluded — re-established), a SINGLETON sym (MUST NOT cancel; §6.2
    // cancels only inherited syms, and a `+` sym never propagates), and a
    // non-sym propagating tag (`branch`, not a symbolic name).
    const entries = [_]TagEntry{
        .{ .name = "sym-trunk", .tagtype = .propagate, .value = null },
        .{ .name = "sym-feature", .tagtype = .propagate, .value = null },
        .{ .name = "sym-adhoc", .tagtype = .singleton, .value = null },
        .{ .name = "branch", .tagtype = .propagate, .value = "trunk" },
    };
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const got = try selectInheritedSyms(arena_state.allocator(), &entries, "feature");
    // Exactly the inherited propagating sym, minus the re-established one: the
    // singleton `sym-adhoc` and the non-sym `branch` are both excluded.
    try std.testing.expectEqual(@as(usize, 1), got.len);
    try std.testing.expectEqualStrings("sym-trunk", got[0]);
}