Skip to content
zit Artifact [688d5ce27b]
Login

Artifact [688d5ce27b]

Artifact 688d5ce27bd13e7932301c71cf88826d5861f012a5bc592533690b66208b9430:


//! 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. A put of NEW in
    /// the same request, when this is the only rename onto NEW and it has no
    /// `bytes`, gives it that put's bytes and 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,
    /// A merge, in place of `parent`: the first is the primary parent, which
    /// the edits apply to and the fork guard checks, and the rest are merge
    /// parents, recorded after it on the P card in the order given, as
    /// `merge` then `commit` records them. The result tree is the primary's
    /// plus the edits; with merge parents the edits may be empty. Each names
    /// a check-in, as `parent` does, and no check-in twice.
    parents: ?[]const []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 = .{},
    /// Filled when set, on every outcome: what the commit was built on.
    report: ?*Report = null,
    /// Answer a resend by its identity rather than its bytes: before the
    /// parent is chosen, a stored check-in with this request's time (to the
    /// millisecond), user and comment, on `branch` when one is named and
    /// built on `parent` when one is named, is the answer, as `existing`.
    /// A client that sends `parent` null cannot otherwise resend: the first
    /// send moved the tip its edits are applied to. Off for the CLI, whose
    /// clock makes every commit a new one.
    find_resend: bool = false,
};

/// What a caller answering for a commit says beyond its name, which only the
/// core knows once it has chosen the parent.
pub const Report = struct {
    /// Where `branch` is copied. The caller's.
    arena: std.mem.Allocator,
    /// The check-in the commit was built on; empty for the first check-in.
    parent: Name = .{},
    /// The branch the check-in is on (or, unchanged, its parent is on).
    branch: []const u8 = "",
    /// The parent has another child on that branch besides this check-in,
    /// so the branch forks here; answered for a resend (`existing`) as for
    /// the first send. Never set for a new branch or an unchanged commit.
    forked: bool = false,
};

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

    pub 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,
    /// A user or branch holding a control byte, or a path with characters
    /// `control_paths.validateNewPath` refuses: a name the manifest would
    /// carry, refused before it is minted into a check-in that can never be
    /// changed. Spaces are fine; the card escapes carry them.
    BadName,
    /// `parent`, or one of `parents`, 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,
    /// Between `beginBatch` and `endBatch`: the lock is held across several
    /// commits, and their appends are made durable by one fsync at the end.
    batching: 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,
    /// Check-ins by D-card time, for `find_resend`. Built from the view on
    /// first use, extended with each check-in this context records, and
    /// dropped whenever another writer's append or a rewrite is noticed.
    resend_index: ?StoredIndex,
    /// Test seam: the next `endBatch` behaves as though its fsync failed.
    fail_next_sync: bool,

    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,
            .batching = false,
            .repo = undefined,
            .repo_stale = false,
            .repo_open = false,
            .tree_store = .{ .gpa = gpa, .log = &self.log },
            .tree_editor = null,
            .renamed_memo = null,
            .resend_index = null,
            .fail_next_sync = false,
        };
        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.batching);
        std.debug.assert(self.hold_lock or self.lock == null);
        self.unlockStore();
        if (self.renamed_memo) |*m| m.arena.deinit();
        self.dropResendIndex();
        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 or a
    /// batch does.
    fn end(self: *Context) void {
        if (!self.hold_lock and !self.batching) self.unlockStore();
    }

    /// Take the lock for several commits, catching up with the store once.
    /// Each `commit` until `endBatch` publishes without its own fsync, so a
    /// caller that answers only after `endBatch` pays one sync for the lot.
    /// Every commit stays independent: one refused leaves the others as they
    /// would be alone, and each is idempotent on its own bytes. On error the
    /// lock is not held and no batch is open.
    pub fn beginBatch(self: *Context, diag: *Diag) Error!void {
        std.debug.assert(!self.hold_lock and !self.batching);
        diag.clear();
        try self.begin(diag);
        self.batching = true;
    }

    /// Make the batch's appends durable in one fsync and release the lock,
    /// whether or not the sync succeeded. A failed sync means none of the
    /// batch's check-ins may be reported as recorded, and the open log is
    /// dropped: it still holds the unsynced frames, and answering a resend
    /// from them would claim bytes the disk may not have. Every later commit
    /// refuses until the context is opened again.
    pub fn endBatch(self: *Context, diag: *Diag) Error!void {
        std.debug.assert(self.batching and self.lock != null);
        diag.clear();
        defer {
            self.batching = false;
            self.unlockStore();
        }
        if (!self.log_open) return ioFailure(diag);
        const failed = self.fail_next_sync or blk: {
            self.log.commit() catch break :blk true;
            break :blk false;
        };
        self.fail_next_sync = false;
        if (!failed) return;
        self.log.deinit(self.gpa);
        self.log_open = false;
        self.key = null;
        self.dropResendIndex();
        diag.set("cannot make the check-ins durable: the store's fsync failed", .{});
        return error.Storage;
    }

    fn dropResendIndex(self: *Context) void {
        if (self.resend_index) |*index| index.deinit(self.gpa);
        self.resend_index = null;
    }

    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` (or
        // since the batch began): only this context's own publishes need
        // folding into the view.
        if (self.hold_lock or self.batching) 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;
            self.dropResendIndex();
        }
        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) FindError {
    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 {
    const facts = try parentFacts(repo, gpa, ma, args, branch, parent, want_skip_base, diag);
    if (facts.refusal) |e| return e;
    return facts.checked;
}

/// `parentChecks`' answer with its refusal held as data: the cards a
/// check-in on `parent` carries are known whether or not the fork guard or
/// the branch check would refuse it, so `commit` can first ask whether that
/// exact check-in is already stored (a resend), which neither check refuses.
const ParentFacts = struct {
    checked: ParentChecked,
    /// Set with its message in the diag.
    refusal: ?error{ NotTip, BranchExists } = null,
};

fn parentFacts(
    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}!ParentFacts {
    var refusal: ?error{ NotTip, BranchExists } = null;
    // 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},
                    );
                }
                refusal = 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 .{ .checked = .{ .syms = &.{}, .skip_base = skip_base }, .refusal = refusal };
    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});
        refusal = error.BranchExists;
    }
    // The parent's inherited sym-* tags to cancel, minus the one we
    // re-establish.
    const prid = repo.checkinRid(parent) orelse return .{ .checked = .{ .syms = &.{}, .skip_base = skip_base }, .refusal = refusal };
    const entries = try repo.state().tagsOf(gpa, prid);
    defer gpa.free(entries);
    const syms = try selectInheritedSyms(ma, entries, bname);
    return .{ .checked = .{ .syms = syms, .skip_base = skip_base }, .refusal = refusal };
}

/// 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_in: 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_in, diag);
    // From here the primary parent is `parent`, whichever way it was named.
    var req = req_in;
    if (req_in.parents) |ps| req.parent = ps[0];
    if (req.find_resend) if (try findResend(ctx, req, diag)) |held| return .{ .existing = held };
    const parent = try resolveParent(ctx, ma, req, diag);
    const merged = try resolveMerged(ctx, ma, req, parent.hash, 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;
    }
    if (req.report) |r| {
        r.parent = if (parent.rid != null) .of(parent.hash) else .{};
        r.branch = try r.arena.dupe(u8, parent.switch_branch orelse parent.branch);
        r.forked = false;
    }

    // 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);
    // A merge is a change of its own: the parents it joins.
    if (changes.len == 0 and merged.len == 0) {
        std.debug.assert(parent.rid != null);
        return .{ .unchanged = .of(parent.hash) };
    }

    var checked: ParentChecked = .{ .syms = &.{} };
    // A refusal from the fork guard or the branch check stands unless the
    // check-in this request makes is already stored: a resend of a commit
    // that landed is answered with its name, not refused by its own child.
    var refusal: ?error{ NotTip, BranchExists } = null;
    var refusal_diag: Diag = .{};
    if (parent.rid != null) {
        const facts = try parentFacts(&ctx.repo, gpa, ma, .{
            .switch_branch = parent.switch_branch,
            .allow_fork = req.allow_fork,
        }, parent.branch, parent.hash, true, &refusal_diag);
        checked = facts.checked;
        refusal = facts.refusal;
    }

    // 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{try pCard(ma, parent.hash, merged)};
    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);
    // Before the hook: a resend of a check-in already stored is answered with
    // its name, and a refused request runs no project check.
    {
        var pdigest: [digest_len]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(plain, &pdigest, .{});
        if (ctx.log.lookup(&pdigest)) |held| if (!ctx.log.isPhantom(held)) {
            var pname_buf: [64]u8 = undefined;
            const held_name = Name.of(artifact.artifactName(.sha3_256, plain, &pname_buf));
            reportFork(ctx, req, parent, held_name.slice());
            return .{ .existing = held_name };
        };
    }
    if (refusal) |e| {
        diag.* = refusal_diag;
        return e;
    }

    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)) {
        reportFork(ctx, req, parent, name.slice());
        return .{ .existing = name };
    };
    reportFork(ctx, req, parent, name.slice());

    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);
    if (ctx.resend_index) |*index| index.add(gpa, timeKey(req.now), name) catch ctx.dropResendIndex();
    return .{ .recorded = name };
}

/// Stored check-ins by their D-card time in milliseconds, for `findStored`.
/// Several can share a time; the list is short in practice and checked one
/// by one. True of the view it was built from; the holder drops it when that
/// view is replaced.
pub const StoredIndex = struct {
    by_time: std.AutoHashMapUnmanaged(u64, std.ArrayList(Name)) = .empty,

    pub fn deinit(self: *StoredIndex, gpa: std.mem.Allocator) void {
        var it = self.by_time.valueIterator();
        while (it.next()) |list| list.deinit(gpa);
        self.by_time.deinit(gpa);
    }

    fn add(self: *StoredIndex, gpa: std.mem.Allocator, time: u64, name: Name) error{OutOfMemory}!void {
        const got = try self.by_time.getOrPut(gpa, time);
        if (!got.found_existing) got.value_ptr.* = .empty;
        try got.value_ptr.append(gpa, name);
    }

    /// One pass over the view's events: O(check-ins) once, then O(1) a
    /// lookup, and kept current by `commit` as it records.
    pub fn build(gpa: std.mem.Allocator, repo: *Repository) error{OutOfMemory}!StoredIndex {
        var index: StoredIndex = .{};
        errdefer index.deinit(gpa);
        const state = repo.state();
        var events = state.eventsIter();
        while (events.next()) |ev| {
            const time = if (state.backing == .derived) state.originalMtimeOf(ev.rid) orelse ev.mtime else ev.mtime;
            try index.add(gpa, time, .of(repo.hashOf(ev.rid)));
        }
        return index;
    }
};

fn timeKey(t: Time) u64 {
    std.debug.assert(t.epoch >= 0 and t.ms <= 999);
    return @as(u64, @intCast(t.epoch)) * 1000 + t.ms;
}

/// A check-in as a resend names it: what `findStored` matches on.
pub const Identity = struct {
    time: Time,
    user: []const u8,
    comment: []const u8,
    /// Any name `resolve` accepts; null matches any parent.
    parent: ?[]const u8 = null,
    /// A merge's parents, primary first: matches only a check-in whose P
    /// card lists exactly these, in this order. Given in place of `parent`.
    parents: ?[]const []const u8 = null,
    /// Null matches any branch.
    branch: ?[]const u8 = null,
};

/// A stored check-in `findStored` matched, and what a commit that made it
/// reports.
pub const Stored = struct {
    name: Name,
    /// Empty for a root check-in.
    parent: Name,
    /// Borrows the view the match was made on.
    branch: []const u8,
    /// Its parent has another child on its branch (`Report.forked`).
    forked: bool,
};

pub const FindError = error{ OutOfMemory, Storage, Corrupt };

/// The stored check-in with `id`'s D time (to the millisecond), user and
/// comment, on `id.branch` and built on `id.parent` when those are named.
/// The one test of "already stored" for both a resend (`find_resend`) and the
/// daemon's read-only lookup, which must never disagree. Reads `repo` and,
/// for a comment the view spilled, `log`; writes nothing and takes no lock.
pub fn findStored(repo: *Repository, log: *logstore.LogStore, index: *const StoredIndex, id: Identity, diag: *Diag) FindError!?Stored {
    if (id.time.epoch < 0 or id.time.ms > 999) return null;
    const candidates = index.by_time.get(timeKey(id.time)) orelse return null;
    const primary_name: ?[]const u8 = if (id.parents) |ps| (if (ps.len == 0) return null else ps[0]) else id.parent;
    const want_parent: ?repostate.Rid = if (primary_name) |p| (repo.resolve(p, .checkin) catch return null) else null;
    for (candidates.items) |*name| {
        const rid = repo.lookup(name.slice()) orelse continue;
        const state = repo.state();
        if (!std.mem.eql(u8, state.userOf(rid) orelse continue, id.user)) continue;
        if (!try sameComment(repo, log, rid, name.slice(), id.comment, diag)) continue;
        const branch = repo.branchOf(rid);
        if (id.branch) |b| if (!std.mem.eql(u8, branch, b)) continue;
        const parent_rid = state.primaryParentOf(rid);
        if (want_parent) |wp| if (parent_rid != wp) continue;
        if (id.parents) |ps| if (!try sameParents(repo, log, name.slice(), ps, diag)) continue;
        var found: Stored = .{ .name = name.*, .parent = .{}, .branch = branch, .forked = false };
        if (parent_rid) |p| {
            found.parent = .of(repo.hashOf(p));
            // A check-in that left its parent's branch started a branch
            // rather than forking one.
            if (std.mem.eql(u8, repo.branchOf(p), branch)) found.forked = hasOtherChildOn(repo, p, branch, name.slice());
        }
        return found;
    }
    return null;
}

/// The stored check-in this request already made, if any (`find_resend`),
/// with what it was built on in the report. Under the lock `commit` took.
fn findResend(ctx: *Context, req: Request, diag: *Diag) Error!?Name {
    if (ctx.resend_index == null) ctx.resend_index = try StoredIndex.build(ctx.gpa, &ctx.repo);
    const found = try findStored(&ctx.repo, &ctx.log, &ctx.resend_index.?, .{
        .time = req.now,
        .user = req.user,
        .comment = req.comment,
        .parent = req.parent,
        .parents = req.parents,
        .branch = req.branch,
    }, diag) orelse return null;
    if (req.report) |r| {
        r.parent = found.parent;
        r.branch = try r.arena.dupe(u8, found.branch);
        r.forked = found.forked;
    }
    return found.name;
}

/// The view holds a check-in's comment unless it spilled it; then the
/// manifest is read, once per candidate, which only a time match reaches.
fn sameComment(repo: *Repository, log: *logstore.LogStore, rid: repostate.Rid, name: []const u8, comment: []const u8, diag: *Diag) FindError!bool {
    if (repo.state().commentOf(rid)) |c| return std.mem.eql(u8, c, comment);
    const gpa = log.gpa;
    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexDigest(name, &digest_buf) orelse return false;
    const lrid = log.lookup(digest) orelse return false;
    const bytes = (log.content(gpa, lrid) catch |e| return contentError(e, diag)) orelse return false;
    defer gpa.free(bytes);
    var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return false,
    };
    defer im.deinit(gpa);
    const c = im.manifest.c orelse return comment.len == 0;
    return std.mem.eql(u8, c.comment, comment);
}

/// The check-in `name`'s P card lists exactly the check-ins `want` names, in
/// order. Read from the manifest: the view's linkage rows do not keep the
/// card's order.
fn sameParents(repo: *Repository, log: *logstore.LogStore, name: []const u8, want: []const []const u8, diag: *Diag) FindError!bool {
    const gpa = log.gpa;
    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexDigest(name, &digest_buf) orelse return false;
    const lrid = log.lookup(digest) orelse return false;
    const bytes = (log.content(gpa, lrid) catch |e| return contentError(e, diag)) orelse return false;
    defer gpa.free(bytes);
    var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return false,
    };
    defer im.deinit(gpa);
    if (im.manifest.p.len == 0) return false;
    var have = im.manifest.p[0].hashes();
    for (want) |w| {
        const h = have.next() orelse return false;
        const rid = repo.resolve(w, .checkin) catch return false;
        if (!std.mem.eql(u8, repo.hashOf(rid), h)) return false;
    }
    return have.next() == null;
}

/// `report.forked`: the parent has a child on its own branch other than
/// `own`, the check-in this request makes (a resend finds itself among the
/// children, and must not read as a fork of its own making).
fn reportFork(ctx: *Context, req: Request, parent: Parent, own: []const u8) void {
    const r = req.report orelse return;
    const prid = parent.rid orelse return;
    if (parent.switch_branch != null) return;
    if (hasOtherChildOn(&ctx.repo, prid, parent.branch, own)) r.forked = true;
}

/// `prid` has a child on `branch` other than `own`.
fn hasOtherChildOn(repo: *Repository, prid: repostate.Rid, branch: []const u8, own: []const u8) bool {
    var children = repo.state().edgesOf(.children, prid);
    var seen: usize = 0;
    while (children.next()) |child| {
        seen += 1;
        std.debug.assert(seen <= repo.state().ridCount());
        if (std.mem.eql(u8, repo.hashOf(child.rid), own)) continue;
        if (std.mem.eql(u8, repo.branchOf(child.rid), branch)) return true;
    }
    return false;
}

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

/// The request checks `commit` makes before it reads anything. A caller that
/// writes a working copy of the edits first (the CLI's before-commit area)
/// runs them itself, so a name the filesystem cannot hold is refused as the
/// name it is.
pub fn checkRequest(req: Request, diag: *Diag) Error!void {
    if (req.parents) |ps| {
        if (req.parent != null) {
            diag.set("name the parent once: 'parent' or 'parents', not both", .{});
            return error.InvalidRequest;
        }
        if (ps.len == 0) {
            diag.set("'parents' names no check-in; the first is the primary parent", .{});
            return error.InvalidRequest;
        }
    }
    const merging = if (req.parents) |ps| ps.len > 1 else false;
    if (req.edits.len == 0 and !merging) {
        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 (control_paths.hasControlByte(b)) {
            diag.set("--branch \"{f}\" holds a control byte; a check-in cannot carry it", .{std.zig.fmtString(b)});
            return error.BadName;
        }
    }
    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 (control_paths.hasControlByte(req.user)) {
        diag.set("--user \"{f}\" holds a control byte; a check-in cannot carry it", .{std.zig.fmtString(req.user)});
        return error.BadName;
    }
    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 checkOldPath(p, diag),
        .rename => |r| {
            try checkOldPath(r.old, diag);
            try checkPath(r.new, diag);
        },
    };
}

/// A delete, or a rename's old side, names a path already in the parent,
/// which stock Fossil may have minted with bytes no new name may hold; it
/// must still be removable. Only the reader's shape applies here, and the
/// parent's tree decides whether it exists.
fn checkOldPath(path: []const u8, diag: *Diag) Error!void {
    if (artifact.isSimplePathname(path)) return;
    diag.set("path \"{f}\" is not a path a check-in can hold", .{std.zig.fmtString(path)});
    return error.InvalidRequest;
}

fn checkPath(path: []const u8, diag: *Diag) Error!void {
    control_paths.validateNewPath(path) catch |e| switch (e) {
        error.IllegalCharacters => {
            diag.set("path \"{f}\" contains illegal characters", .{std.zig.fmtString(path)});
            return error.BadName;
        },
        error.InvalidPath, error.ReservedPath => {
            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 full names of `req.parents` after the first, in order: the merge
/// parents the P card lists after `primary`. Empty for a commit with one
/// parent.
fn resolveMerged(ctx: *Context, ma: std.mem.Allocator, req: Request, primary: []const u8, diag: *Diag) Error![]const []const u8 {
    const ps = req.parents orelse return &.{};
    if (ps.len < 2) return &.{};
    const repo = &ctx.repo;
    const out = try ma.alloc([]const u8, ps.len - 1);
    for (ps[1..], out, 1..) |name, *hash, i| {
        const rid = repo.resolve(name, .checkin) catch |e| {
            switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                error.NotFound => diag.set("merge parent '{s}' not found", .{name}),
                error.Ambiguous => diag.set("name '{s}' is ambiguous", .{name}),
            }
            return error.ParentNotFound;
        };
        const full = repo.hashOf(rid);
        if (repo.checkinRid(full) == null) {
            diag.set("'{s}' is not a check-in", .{name});
            return error.ParentNotFound;
        }
        // Fossil's P card holds a check-in once; a repeat would be a merge of
        // a parent with itself.
        if (std.mem.eql(u8, full, primary)) {
            diag.set("parents[{d}] '{s}' is the primary parent again; a check-in is a parent once", .{ i, name });
            return error.InvalidRequest;
        }
        for (out[0 .. i - 1]) |earlier| if (std.mem.eql(u8, earlier, full)) {
            diag.set("parents[{d}] '{s}' names a merge parent already listed; a check-in is a parent once", .{ i, name });
            return error.InvalidRequest;
        };
        hash.* = try ma.dupe(u8, full);
    }
    return out;
}

/// The P card: `primary`, then each merge parent, space-joined, as
/// `runCommitInner` composes it for a checkout with merges pending.
fn pCard(ma: std.mem.Allocator, primary: []const u8, merged: []const []const u8) error{OutOfMemory}!artifact.PCard {
    if (merged.len == 0) return .{ .parents = primary, .count = 1 };
    var tokens: std.ArrayList(u8) = .empty;
    try tokens.appendSlice(ma, primary);
    for (merged) |m| {
        try tokens.append(ma, ' ');
        try tokens.appendSlice(ma, m);
    }
    return .{ .parents = tokens.items, .count = 1 + merged.len };
}

/// 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 a delete edit removed and no rename has taken yet: the one path
    // two edits may name, a rename onto a file the same request deletes.
    var deleted: 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 (try foldRenamePuts(ma, 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 absentOrDirectory(ctx, ma, base, path, diag);
            try changes.append(ma, .{ .path = path, .card = null });
            try removed.put(ma, path, {});
            try deleted.put(ma, path, {});
        },
        .rename => |r| {
            if (std.mem.eql(u8, r.old, r.new)) {
                diag.set("'{f}' cannot be renamed onto itself; nothing was written", .{std.zig.fmtString(r.old)});
                return error.InvalidRequest;
            }
            try touch(&touched, ma, r.old, diag);
            // NEW may be a file this request deletes: the rename replaces it.
            const onto_deleted = deleted.remove(r.new);
            if (!onto_deleted) try touch(&touched, ma, r.new, diag);
            const source = (try lookupParent(ctx, ma, base, r.old, diag)) orelse return absentOrDirectory(ctx, ma, base, r.old, diag);
            if (source.mode == .symlink) return notRegular(diag, r.old);
            if (!onto_deleted and try lookupParent(ctx, ma, base, r.new, diag) != null) {
                diag.set("'{f}' is already in the parent, so '{f}' cannot be renamed onto it; nothing was written", .{ std.zig.fmtString(r.new), std.zig.fmtString(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;
}

/// A put of a rename's NEW name in the same request is that rename's new
/// content and mode: the edit `commit -R --rename OLD NEW --stdin NEW`
/// builds, so a client that sends the two separately records the same
/// check-in. Only a rename that is the one rename onto that name, with no
/// content of its own, takes a put, and only one; every other repeat of a
/// path is left for `touch` to refuse.
fn foldRenamePuts(ma: std.mem.Allocator, edits: []const Edit) error{OutOfMemory}![]const Edit {
    const shared = std.math.maxInt(usize);
    // NEW name to the index of the one rename onto it, or `shared`.
    var onto: std.StringHashMapUnmanaged(usize) = .empty;
    for (edits, 0..) |e, i| if (e == .rename) {
        const got = try onto.getOrPut(ma, e.rename.new);
        got.value_ptr.* = if (got.found_existing) shared else i;
    };
    if (onto.count() == 0) return edits;
    var folded: ?[]Edit = null;
    var dropped: []bool = &.{};
    for (edits, 0..) |e, i| {
        if (e != .put) continue;
        const at = onto.get(e.put.path) orelse continue;
        if (at == shared or edits[at].rename.bytes != null) continue;
        if (folded == null) {
            folded = try ma.dupe(Edit, edits);
            dropped = try ma.alloc(bool, edits.len);
            @memset(dropped, false);
        }
        const r = &folded.?[at].rename;
        if (r.bytes != null) continue; // a second put of the name stays, and is refused
        r.bytes = e.put.bytes;
        r.mode = e.put.mode;
        dropped[i] = true;
    }
    const out = folded orelse return edits;
    var n: usize = 0;
    for (out, dropped) |e, gone| if (!gone) {
        out[n] = e;
        n += 1;
    };
    return out[0..n];
}

/// 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("'{f}' is named by more than one edit", .{std.zig.fmtString(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("'{f}' is a symbolic link in the parent; --stdin records regular files only", .{std.zig.fmtString(path)});
    return error.PathConflict;
}

/// A delete or rename of a path the parent does not hold as a file: named as
/// a directory when the parent holds files under it, since edits name files
/// one by one and never expand a directory.
fn absentOrDirectory(ctx: *Context, ma: std.mem.Allocator, base: *const Base, path: []const u8, diag: *Diag) Error {
    const no_removals: std.StringHashMapUnmanaged(void) = .empty;
    const dir_prefix = try std.mem.concat(ma, u8, &.{ path, "/" });
    if (try firstUnder(ctx, ma, base, dir_prefix, &no_removals, diag)) |inside| {
        diag.set("'{f}' is a directory in the parent (it holds '{f}'); name its files one by one; nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(inside) });
        return error.PathConflict;
    }
    return absent(diag, path);
}

fn absent(diag: *Diag, path: []const u8) Error {
    diag.set("'{f}' is not in the parent; nothing was written", .{std.zig.fmtString(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("'{f}' is a file in the parent, so '{f}' cannot be one under it; nothing was written", .{ std.zig.fmtString(above), std.zig.fmtString(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("'{f}' is a directory in the parent (it holds '{f}'); nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(other) });
        return error.PathConflict;
    };
    if (try firstUnder(ctx, ma, base, dir_prefix, removed, diag)) |inside| {
        diag.set("'{f}' is a directory in the parent (it holds '{f}'); nothing was written", .{ std.zig.fmtString(path), std.zig.fmtString(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);
    // A batch syncs once, at `endBatch`.
    if (!ctx.batching) 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]);
}