Skip to content
zit Artifact [f02d2eb48f]
Login

Artifact [f02d2eb48f]

Artifact f02d2eb48f94115961a1b3a7420496af17854042084a9a9d46ca09a499b2557c:


//! Transactional working-tree materialization for `zit update CHECKIN`.
//!
//! This layer is deliberately the sole owner of update's filesystem effects:
//! it validates and expands the requested manifest, stages every payload under
//! an exclusive worktree-local transaction directory, proves the current tree
//! clean, then writes `.zit-managed` before atomically committing
//! `.zit-checkout`.

const std = @import("std");
const repeat = @import("repeat.zig").repeat;
const rfc_vectors = @import("rfc_vectors.zig");
const artifact = @import("artifact.zig");
const checkout = @import("checkout.zig");
const logstore = @import("logstore.zig");
const crc32 = @import("crc32.zig");
const managed = @import("managed.zig");
const repository = @import("repository.zig");
const repocache = @import("repocache.zig");
const repostate = @import("repostate.zig");
const control_paths = @import("control_paths.zig");
const cone_mod = @import("cone.zig");
const settings = @import("settings.zig");
const ignore = @import("ignore.zig");

/// The single-level undo/redo journal (contracts/undo.md). Re-exported so the
/// CLI reaches one instance — the same one this module writes through the
/// `record` seam below (mutual import; Zig resolves it, no comptime cycle).
pub const journal = @import("undo.zig");

/// How one materialization decides which working-tree manifest files to write.
/// Fossil derives this from a repository setting; zit does NOT read settings —
/// the opt-in is explicit and per checkout, so what a checkout promises is
/// exactly what the user asked it for. `open --manifest` is the only verb that
/// `set`s it; every other materialization `inherit`s what the checkout record
/// already carries, which is what makes the files stay refreshed.
pub const ManifestOpt = union(enum) {
    /// Use the checkout's own remembered selection.
    inherit,
    /// Use exactly these selectors, and remember them.
    set: []const u8,
    /// Use the checkout's remembered selection, falling back to the
    /// PROJECT's `manifest` setting when it has none.
    ///
    /// fossil makes `manifest` a project setting so a clone gets build stamps
    /// without every developer opting in by hand; zit had it only as a
    /// per-checkout flag. Passed in rather than read here because resolving a
    /// setting spans the config frame, a tracked file and the operator's global
    /// file — none of which this module should know about. The caller resolves;
    /// this module only applies.
    inherit_or: []const u8,
};

/// The working-tree file each `checkout.manifest_selectors` character names,
/// in the same order.
///
/// Public because the Git importer's mirror classifier reads the same list:
/// the set of root files it may drop from an imported Fossil→Git mirror has
/// to be exactly the set this module writes, and two copies of the list could
/// drift into disagreeing about which names are metadata.
pub const manifest_paths = [_][]const u8{ "manifest", "manifest.uuid", "manifest.tags" };

/// The line fossil appends to the on-disk `manifest` so the file cannot be
/// mistaken for — or re-ingested as — the artifact it was copied from.
///
/// Public for the same reason as `manifest_paths`: the importer's classifier
/// undoes exactly this transformation, and it must undo the bytes this writer
/// produces rather than a second literal that merely looks the same.
pub const sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n";

/// The reason text every verb uses for `ApplyError.StateRecordInvalid`. Shared
/// so the update, open and merge diagnostics cannot drift into disagreeing
/// about what the error means — and, in particular, so none of them can
/// silently collapse back into the `Corrupt` "repository is corrupt" wording.
/// The two errors are different claims: `Corrupt` means stored bytes do not
/// match the name they are stored under, while this one means a stored
/// artifact was not usable as a check-in state record, which says nothing
/// about the repository's integrity.
pub const state_record_invalid_reason = "a stored artifact is not a usable check-in state record";

/// Errors intentionally describe the caller-visible update decision rather
/// than leaking individual `std.Io` platform errors.
pub const ApplyError = error{
    /// The checkout carries a sparse cone and the operation would apply
    /// changes it cannot represent yet -- sparse-cone RFC section 4.4's
    /// record-level application is not implemented in this wave, and
    /// proceeding would silently lose the incoming out-of-cone edits at the
    /// next commit (they end up in neither the baseline nor the worktree).
    /// Refusal is the fail-closed interim; the diagnostic names the class.
    SparseMergeUnsupported,
    /// A file the check-in names has no artifact in this repository, or the
    /// artifact it names does not hash to the name. Distinct from `Corrupt`
    /// because it is a different claim: the repository read fine and the
    /// check-in parsed fine — one file's CONTENT is absent or wrong, and the
    /// path and hash below say which. `verify` cannot surface it, because it
    /// checks that stored artifacts are valid, never that referenced ones exist.
    FileContentMissing,
    InvalidCheckinId,
    CheckinNotFound,
    CheckinContentMissing,
    /// §6.3: the target's R aggregate content checksum disagrees with the
    /// content its own F run names — the check-in was authored inconsistently
    /// (or the store's blobs are not what the manifest attests). Fail-closed
    /// exactly as Fossil's default repo-cksum checkout does; materializing a
    /// tree the check-in itself disowns helps no one.
    AggregateChecksumMismatch,
    CheckinNameMismatch,
    TargetNotManifest,
    BaselineNotFound,
    BaselineContentMissing,
    BaselineNameMismatch,
    BaselineNotManifest,
    /// A stored artifact could not be turned into a check-in state record.
    /// This is a statement about one artifact's shape, not about repository
    /// integrity — see `Corrupt` for that.
    StateRecordInvalid,
    InvalidPath,
    ReservedPath,
    SymlinkUnsupported,
    CurrentStateInvalid,
    WorktreeNotClean,
    UnmanagedCollision,
    Storage,
    Corrupt,
    OutOfMemory,
    TransactionUnavailable,
    RollbackFailed,
    CleanupFailed,
};

const ParentError = ApplyError || error{PathMissing};
const TestFault = enum {
    after_materialize,
    before_cleanup,
    merge_mid_apply,
};

// Tests use these boundaries to deterministically exercise the transactional
// branches that ordinary filesystem failures cannot reliably reach. Production
// builds always return false and therefore retain their normal behavior.
var test_fault: ?TestFault = null;

fn shouldInjectTestFault(point: TestFault) bool {
    if (comptime @import("builtin").is_test) return test_fault == point;
    return false;
}

const PlanEntry = struct {
    path: []const u8,
    name: []const u8,
    digest: [logstore.max_hash_len]u8,
    digest_len: u8,
    mode: artifact.FileMode,
    /// This path is byte-identical (same content digest, same mode) in the
    /// BASELINE plan, and `requireClean` has already proven the file on disk
    /// matches the baseline -- so the correct on-disk state for it under the
    /// target is the file that is already there. Every mutation-phase loop
    /// skips it: it is not staged in either direction, not journaled, not
    /// deleted, not rewritten. Set by `markUnchanged` on `update`'s plans;
    /// never set on `open`'s (whose baseline is empty) or on any other path,
    /// so their behavior is exactly what it was.
    unchanged: bool = false,
    /// Bytes to materialize for this path INSTEAD of the check-in content
    /// `digest` names — the working tree's own version, carried across a
    /// materialization that would otherwise overwrite it (`CarrySet`). Borrowed
    /// from the caller's arena, which outlives the call. Null on every plan
    /// built for a clean tree, so nothing about those changes.
    ///
    /// A carried entry is a deliberate disagreement between the plan's digest
    /// and the bytes that land on disk, which is why `stagePlan` writes these
    /// verbatim (no `artifact.verify`, there being nothing to verify against)
    /// and why `VStat.writeForPlan` must skip the entry: a sidecar row pairing
    /// this path's stat with the check-in's digest would assert the file is
    /// unmodified, and the next `scanTree` would believe it.
    carry: ?[]const u8 = null,

    fn deinit(self: *PlanEntry, gpa: std.mem.Allocator) void {
        gpa.free(self.path);
        gpa.free(self.name);
        self.* = undefined;
    }

    fn digestSlice(self: *const PlanEntry) []const u8 {
        return self.digest[0..self.digest_len];
    }
};

const Plan = struct {
    entries: std.ArrayList(PlanEntry) = .empty,
    /// The target manifest's R aggregate checksum (32 lowercase hex), when it
    /// carries one — held so materialization can verify the plan's content
    /// against the check-in's own attestation (§6.3). Null for a manifest
    /// without R (legal: Fossil's repo-cksum can be off).
    r_checksum: ?[32]u8 = null,

    fn deinit(self: *Plan, gpa: std.mem.Allocator) void {
        for (self.entries.items) |*entry| entry.deinit(gpa);
        self.entries.deinit(gpa);
        self.* = undefined;
    }

    /// Binary search for `path`, exploiting the ascending-byte-order invariant
    /// `validateTopology` establishes. Returns the index when present, and
    /// otherwise the index the path would be inserted at.
    fn locate(self: *const Plan, path: []const u8) struct { idx: usize, found: bool } {
        var lo: usize = 0;
        var hi: usize = self.entries.items.len;
        while (lo < hi) {
            const mid = lo + (hi - lo) / 2;
            switch (std.mem.order(u8, self.entries.items[mid].path, path)) {
                .lt => lo = mid + 1,
                .gt => hi = mid,
                .eq => return .{ .idx = mid, .found = true },
            }
        }
        return .{ .idx = lo, .found = false };
    }

    /// Make `path` materialize `content` verbatim, inserting the entry when the
    /// plan does not already have it. A carried entry keeps no meaningful
    /// digest: its bytes are the working tree's and name no artifact, so the
    /// digest is zeroed rather than left describing content that will not be
    /// written. `stagePlan` never looks it up (it writes `carry` directly) and
    /// `VStat.writeForPlan` abandons the sidecar entirely for such a plan, so
    /// there is no reader left to mislead.
    fn setCarried(self: *Plan, gpa: std.mem.Allocator, path: []const u8, content: Carry.Content) ApplyError!void {
        const at = self.locate(path);
        if (at.found) {
            const entry = &self.entries.items[at.idx];
            entry.carry = content.bytes;
            entry.mode = content.mode;
            entry.unchanged = false;
            return;
        }
        const owned_path = gpa.dupe(u8, path) catch return error.OutOfMemory;
        errdefer gpa.free(owned_path);
        const owned_name = gpa.dupe(u8, "") catch return error.OutOfMemory;
        errdefer gpa.free(owned_name);
        self.entries.insert(gpa, at.idx, .{
            .path = owned_path,
            .name = owned_name,
            .digest = std.mem.zeroes([logstore.max_hash_len]u8),
            .digest_len = 0,
            .mode = content.mode,
            .carry = content.bytes,
        }) catch return error.OutOfMemory;
    }

    /// Drop `path` from the plan if it is there. Absent is success: a carry may
    /// name a path neither check-in has (locally added, then locally deleted).
    fn removePath(self: *Plan, gpa: std.mem.Allocator, path: []const u8) void {
        const at = self.locate(path);
        if (!at.found) return;
        var entry = self.entries.orderedRemove(at.idx);
        entry.deinit(gpa);
    }

    fn add(self: *Plan, gpa: std.mem.Allocator, file: artifact.FCard) ApplyError!void {
        const name = file.hash orelse return;
        try validateWorktreePath(file.filename);

        var digest: [logstore.max_hash_len]u8 = undefined;
        const decoded = try decodeValidatedName(name, &digest);
        const path = gpa.dupe(u8, file.filename) catch return error.OutOfMemory;
        errdefer gpa.free(path);
        const owned_name = gpa.dupe(u8, name) catch return error.OutOfMemory;
        errdefer gpa.free(owned_name);

        self.entries.append(gpa, .{
            .path = path,
            .name = owned_name,
            .digest = digest,
            .digest_len = @intCast(decoded.len),
            .mode = file.mode,
        }) catch return error.OutOfMemory;
    }

    fn validateTopology(self: *const Plan) ApplyError!void {
        if (self.entries.items.len < 2) return;
        for (self.entries.items[1..], self.entries.items[0 .. self.entries.items.len - 1]) |entry, previous| {
            if (std.mem.eql(u8, entry.path, previous.path)) return error.InvalidPath;
            if (isPathPrefix(previous.path, entry.path)) return error.InvalidPath;
        }
    }
};

const LoadedManifest = struct {
    bytes: []u8,
    manifest: artifact.Manifest,
    /// Non-null when `parseForImport` normalized a legacy trailing-separator F
    /// card: the manifest borrows this copy, not `bytes` (read-path only).
    normalized: ?[]u8 = null,

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

const BaselineContext = struct {
    expected_hash: ?[]const u8 = null,
    manifest: ?*const artifact.Manifest = null,
};

/// The derived-state result of `assembleState`. Published (with
/// `assembleState`) as the DST-facing entry into update's log→state
/// derivation, so `src/sim/assembly_sweep.zig` can drive the §4
/// unclassifiable-artifact rule against an independent oracle without going
/// through the worktree-mutating `applyCheckin` path.
pub const StateAssembly = struct {
    gpa: std.mem.Allocator,
    mem: *repostate.MemStore,
    state: repostate.RepoState,
    target_rid: repostate.Rid,

    pub fn deinit(self: *StateAssembly) void {
        self.state.deinit();
        self.mem.deinit();
        self.gpa.destroy(self.mem);
        self.* = undefined;
    }
};

const OpenedParent = struct {
    dir: std.Io.Dir,
    name: []const u8,
    owned: bool,

    fn close(self: OpenedParent, io: std.Io) void {
        if (self.owned) self.dir.close(io);
    }
};

/// The last parent directory a plan walk opened, kept open across entries.
/// A plan is path-sorted, so consecutive entries overwhelmingly share a parent;
/// without this every entry re-walked its directory chain component by
/// component -- one openat+close per level, several times per file -- which
/// was the single largest cost of materializing a tree (~31% of wall).
/// `prefix` borrows the plan entry's own path bytes, which outlive the walk.
const ParentCache = struct {
    dir: ?std.Io.Dir = null,
    prefix: []const u8 = "",

    fn deinit(self: *ParentCache, io: std.Io) void {
        if (self.dir) |*d| d.close(io);
        self.dir = null;
        self.prefix = "";
    }
};

/// `openParentNoFollow` through a `ParentCache`: a hit hands back the cached
/// handle (never owned by the caller), a miss walks, then caches the result.
fn openParentCached(
    cache: *ParentCache,
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    create: bool,
    created_directories: ?*CreatedDirectories,
) ParentError!OpenedParent {
    const slash = std.mem.lastIndexOfScalar(u8, path, '/');
    const prefix = if (slash) |i| path[0..i] else "";
    const name = if (slash) |i| path[i + 1 ..] else path;
    if (cache.dir) |d| {
        if (std.mem.eql(u8, cache.prefix, prefix)) return .{ .dir = d, .name = name, .owned = false };
    }
    const opened = try openParentNoFollow(io, root, path, create, created_directories);
    cache.deinit(io);
    if (opened.owned) {
        cache.dir = opened.dir;
        cache.prefix = prefix;
    }
    return .{ .dir = opened.dir, .name = opened.name, .owned = false };
}

const ExistingPath = union(enum) {
    missing,
    stat: std.Io.File.Stat,
};

/// Directories made while materializing the target are held as borrowed
/// slices into the already-owned plan. Capacity is reserved before any
/// worktree mutation so recording a successful `createDir` cannot fail.
const CreatedDirectories = struct {
    paths: std.ArrayList([]const u8) = .empty,

    fn init(gpa: std.mem.Allocator, plan: *const Plan) ApplyError!CreatedDirectories {
        var capacity: usize = 0;
        for (plan.entries.items) |entry| {
            for (entry.path) |byte| {
                if (byte == '/') capacity += 1;
            }
        }

        var result = CreatedDirectories{};
        result.paths.ensureTotalCapacity(gpa, capacity) catch return error.OutOfMemory;
        return result;
    }

    fn deinit(self: *CreatedDirectories, gpa: std.mem.Allocator) void {
        self.paths.deinit(gpa);
        self.* = undefined;
    }

    fn record(self: *CreatedDirectories, path: []const u8) void {
        self.paths.appendAssumeCapacity(path);
    }

    fn remove(self: *const CreatedDirectories, io: std.Io, worktree: std.Io.Dir) ApplyError!void {
        var index = self.paths.items.len;
        while (index > 0) {
            index -= 1;
            const path = self.paths.items[index];
            const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
                error.PathMissing => continue,
                else => return @errorCast(err),
            };
            defer parent.close(io);
            parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
                error.FileNotFound => {},
                error.DirNotEmpty => return error.Storage,
                else => return error.Storage,
            };
        }
    }
};

/// Materialize canonical `target_id` into `worktree`.
///
/// `target_id` is a public canonical artifact name. It is decoded to a raw
/// digest for every `LogStore.lookup`; textual names are never used as log
/// index keys.
/// The checkout's stored sparse cone (container section 5), or null when
/// dense. A present-but-unparseable cone is authoritative state that fails
/// closed as Corrupt -- never silently treated as dense, which would make a
/// damaged byte quietly rematerialize an 80k-file tree.
pub fn readStoredCone(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError!?cone_mod.Cone {
    const payload = checkout.readSectionAlloc(gpa, io, worktree, .cone) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Storage => return error.Storage,
        error.Corrupt => return error.Corrupt,
    };
    const bytes = payload orelse return null;
    defer gpa.free(bytes);
    const parsed = cone_mod.Cone.parse(gpa, bytes) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.InvalidMember, error.ReservedMember, error.Bounds => return error.Corrupt,
        error.DeadExclusion, error.Contradiction => return error.Corrupt,
    };
    return parsed;
}

/// The cone this materialization applies -- the `ManifestOpt` pattern.
/// `.inherit` uses the checkout's stored cone (dense when none); `.set`
/// replaces it (null payload = return to dense). The record and cone commit
/// in the same container splice as the managed set.
pub const SparseOpt = union(enum) {
    inherit,
    set: ?*const cone_mod.Cone,
};

/// One path the working tree keeps across a materialization that would
/// otherwise overwrite it with the target's version.
///
/// Both endpoints are named because both plans are staged: `after` is what must
/// be on disk when the update commits — for a dirty `update`, the three-way
/// merge of (current check-in, working file, target) — and `before` is what is
/// on disk right now, which is what a failed update has to put back. Supplying
/// only `after` would leave the rollback image holding the CHECK-IN's content
/// for these paths, so an update that failed halfway would restore committed
/// bytes over the operator's edits: a rollback that loses work.
///
/// Either side may be null, meaning the path does not exist at that endpoint —
/// `after == null` is how a local delete survives a target that still carries
/// the file, and `before == null` is a path the merge adds.
///
/// A carry always wins over the target plan, because it was computed FROM the
/// target: the merge already decided what the target's contribution to this
/// path is, so re-applying the plan's own content on top would undo it.
pub const Carry = struct {
    path: []const u8,
    after: ?Content,
    before: ?Content,

    pub const Content = struct {
        bytes: []const u8,
        mode: artifact.FileMode = .regular,
    };
};

/// Create the directories the `empty-dirs` setting names, so
/// a project can version a tree shape that carries no files. Fossil does it
/// from `ensure_empty_dirs_created` (update.c, checkout.c) after a checkout or
/// an update; zit calls it from the same three verbs.
///
/// Each pattern is a LITERAL relative directory name, not a glob — fossil
/// splits the value with the glob tokenizer but then uses each pattern as a
/// path, so `PatternIter` supplies the split and nothing matches anything.
/// Missing parents are created with it.
///
/// Three deliberate rules:
///   * a pattern the worktree-path rules reject is warned about and skipped.
///     Fossil does not check, but this value can arrive from a VERSIONED file,
///     and a check-in must not be able to mint `.zit` or escape the tree.
///   * a name already held by a non-directory gets one warning and is left
///     alone; nothing here ever deletes or replaces.
///   * a creation failure is a warning, not an error. An empty directory is a
///     convenience, and losing one must never cost the operator the
///     materialization that just succeeded — so `ApplyError` does not widen.
pub fn ensureEmptyDirs(io: std.Io, worktree: std.Io.Dir, patterns: []const u8, warn: *std.Io.Writer) void {
    var it: ignore.PatternIter = .init(patterns);
    // Bound: `PatternIter.next` advances at least one byte per pattern, so the
    // loop runs at most `patterns.len` times.
    var seen: usize = 0;
    while (it.next()) |dir| : (seen += 1) {
        std.debug.assert(seen <= patterns.len);
        std.debug.assert(dir.len != 0);
        control_paths.validateWorktreePath(dir) catch {
            warn.print("zit: empty-dirs: '{s}' is not a directory this checkout may create\n", .{dir}) catch {};
            continue;
        };
        if (worktree.statFile(io, dir, .{ .follow_symlinks = false })) |st| {
            if (st.kind == .directory) continue;
            warn.print("zit: empty-dirs: '{s}' exists and is not a directory\n", .{dir}) catch {};
            continue;
        } else |err| switch (err) {
            error.FileNotFound, error.NotDir => {},
            else => {
                warn.print("zit: empty-dirs: cannot inspect '{s}'\n", .{dir}) catch {};
                continue;
            },
        }
        worktree.createDirPath(io, dir) catch {
            warn.print("zit: empty-dirs: cannot create directory '{s}'\n", .{dir}) catch {};
        };
    }
}

pub fn applyCheckin(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    target_id: []const u8,
    manifest: ManifestOpt,
    sparse: SparseOpt,
) ApplyError!void {
    return applyCheckinWith(gpa, io, worktree, log, target_id, manifest, sparse, null, null);
}

/// `applyCheckin` with the caller's already-open `Repository`, when it has
/// one. The target's branch and tags are read from it instead of opening a
/// second repository for the lookup -- a full repo-state derive, which was a
/// fifth of `zit open`'s wall time on a 146k-artifact store. A caller
/// without one (the unit tests, the post-fetch retry) passes null and pays
/// the derive.
pub fn applyCheckinWith(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    target_id: []const u8,
    manifest: ManifestOpt,
    sparse: SparseOpt,
    repo: ?*repository.Repository,
    /// How to keep working-tree content across the move. Null is the
    /// plain materialization every caller but `update` wants: the tree must be
    /// clean or the update refuses, exactly as before this parameter existed.
    carry_resolver: ?CarryResolver,
) ApplyError!void {
    // Resolve the cone this materialization runs under. Read before any plan
    // is built; the stored cone (when inheriting) must outlive both plans.
    var inherited_cone: ?cone_mod.Cone = null;
    defer if (inherited_cone) |*c| c.deinit(gpa);
    const effective_cone: ?*const cone_mod.Cone = switch (sparse) {
        .set => |c| c,
        .inherit => blk: {
            inherited_cone = try readStoredCone(gpa, io, worktree);
            break :blk if (inherited_cone) |*c| c else null;
        },
    };

    // The section update the commit splice carries: only `.set` changes the
    // stored cone; `.inherit` carries it forward untouched (null here).
    var cone_payload: ?[]u8 = null;
    defer if (cone_payload) |cp| gpa.free(cp);
    const cone_section_update: ?checkout.SectionUpdate = switch (sparse) {
        .inherit => null,
        .set => |c| blk: {
            if (c) |real| {
                cone_payload = real.serialize(gpa) catch return error.OutOfMemory;
                break :blk .{ .tag = .cone, .payload = cone_payload.? };
            }
            break :blk .{ .tag = .cone, .payload = null }; // return to dense
        },
    };

    var target = try buildPlan(gpa, log, target_id, effective_cone);
    defer target.deinit(gpa);
    if (effective_cone == null) {
        try verifyPlanAggregate(gpa, log, &target);
    } else {
        // Sparse-cone RFC section 4.3 (verification side): a present R card
        // is a WHOLE-TREE aggregate, so a coned plan can never satisfy it.
        // The aggregate pass reads blobs from the LOG, not the worktree, and
        // the store is dense in Wave 1 -- so verify over a dense plan built
        // for exactly this check. The cone narrows materialization, never R.
        var dense_probe = try buildPlan(gpa, log, target_id, null);
        defer dense_probe.deinit(gpa);
        try verifyPlanAggregate(gpa, log, &dense_probe);
    }

    // The effective manifest opt-in for this materialization AND for the record
    // it commits. Read before anything mutates, and held in a function-scoped
    // buffer the borrowed slice outlives.
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const manifest_raw = switch (manifest) {
        // An explicit choice of NONE is remembered as the
        // sentinel, so the project setting cannot switch stamps back on at the
        // next update; "" stays the never-chosen state.
        .set => |f| if (settings.manifestFlags(f).len == 0) checkout.manifest_off else f,
        .inherit => blk: {
            const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);
            break :blk if (cur) |c| c.manifest_flags else "";
        },
        .inherit_or => |project| blk: {
            const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);
            const own = if (cur) |c| c.manifest_flags else "";
            // The checkout's own choice wins: an operator who ran
            // `open --manifest=off` to turn stamps off must not have the
            // project setting turn them back on at the next update.
            break :blk if (own.len != 0) own else project;
        },
    };
    // What the record below stores: the sentinel verbatim, anything else as
    // the normalized letters, so a stale spelling can never be re-stored.
    const manifest_stored = if (std.mem.eql(u8, manifest_raw, checkout.manifest_off)) checkout.manifest_off else settings.manifestFlags(manifest_raw);
    // THE manifest normalizer, applied once: fossil accepts `on`/`1`/`true`/
    // `yes` (and their false-ish opposites) wherever it accepts the selector
    // letters (`db.c db_get_manifest_setting`). Wrapping the switch RESULT
    // covers open's `.set`, update's `.inherit_or`, bisect's `.inherit`, the
    // project setting, and the record stored below, with no call-site edits.
    // Idempotent, so re-inheriting already-normalized letters is a no-op.
    const manifest_flags = checkout.manifestSelectors(manifest_stored);

    var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer old_managed.deinit();
    // The ONE scan of the working tree. Without a resolver it is the historical
    // refusing scan; with one it collects instead, and the resolver decides what
    // to do about each divergence. A clean tree yields no divergences and so
    // never reaches the resolver, which is why carrying costs a clean update
    // nothing at all.
    var divergences: std.ArrayList(Divergence) = .empty;
    defer divergences.deinit(gpa);
    var old = if (carry_resolver == null)
        try requireClean(gpa, io, worktree, log, &old_managed)
    else
        try scanTree(gpa, io, worktree, log, &old_managed, &divergences);
    defer old.deinit(gpa);

    // The divergence paths borrow `old` and `old_managed`, both of which
    // outlive this call. `Refused` reads as WorktreeNotClean because that is
    // precisely what it means — the tree diverges in a way this move cannot
    // reconcile — and the resolver has already named the paths.
    const carry: []const Carry = if (divergences.items.len == 0) &.{} else blk: {
        const r = carry_resolver.?;
        break :blk r.resolve(r.ctx, divergences.items) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            error.Refused => return error.WorktreeNotClean,
        };
    };
    const want_tags = std.mem.indexOfScalar(u8, manifest_flags, 't') != null;
    var facts = cachedTargetFacts(gpa, io, worktree, target_id, want_tags, repo) orelse
        try deriveTargetFacts(gpa, log, target_id, want_tags);
    defer facts.deinit(gpa);
    const branch = facts.branch;

    // Bulk-built from the plan's own (sorted, unique) order: the one-at-a-time
    // `insert` loop this replaces was quadratic and measured at 23.9s of a
    // 68k-file update -- larger than every real phase of the verb combined.
    const plan_paths = gpa.alloc([]const u8, target.entries.items.len) catch return error.OutOfMemory;
    defer gpa.free(plan_paths);
    for (target.entries.items, plan_paths) |entry, *slot| slot.* = entry.path;
    var desired_managed = managed.ManagedSet.fromSortedPaths(gpa, plan_paths) catch |err| return mapManagedError(err);
    defer desired_managed.deinit();
    // The managed set this update will commit is EXACTLY the target plan's
    // membership: one entry per plan path. `validateTopology` proved those paths
    // unique (and non-nesting), so every dedup insert added a distinct path —
    // the two can never disagree in count, the first half of the managed-set /
    // record / tree agreement the commit below depends on.
    std.debug.assert(desired_managed.iterate().len == target.entries.items.len);

    // Both plans are final from here on, so the unchanged marks computed now
    // hold for every phase below.
    try markUnchanged(gpa, &target, &old);

    // Overlay the carries LAST, so nothing above can be computed from them:
    // `verifyPlanAggregate` has already checked the target's R card against the
    // check-in's own content (a carried byte is not the check-in's and has no
    // place in its attestation), `desired_managed` is the target's membership,
    // and `markUnchanged` has compared the two plans as the store describes
    // them. Overlaying clears `unchanged` on every path it touches, so a
    // carried path is always written even when the two check-ins agree about it.
    if (carry.len != 0) {
        try overlayCarry(gpa, &target, &old, carry, &desired_managed);
        // The managed set must still describe exactly what the target plan
        // materializes, or the commit records a membership the tree does not
        // have. `overlayCarry` maintains both together for this reason.
        std.debug.assert(desired_managed.iterate().len == target.entries.items.len);
    }

    const txn_name = try createTransaction(gpa, io, worktree);
    defer gpa.free(txn_name);
    var transaction_live = true;
    defer if (transaction_live) worktree.deleteTree(io, txn_name) catch {};

    var txn = worktree.openDir(io, txn_name, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
    defer txn.close(io);
    txn.createDir(io, "next", .default_dir) catch return error.Storage;
    txn.createDir(io, "previous", .default_dir) catch return error.Storage;
    var next = txn.openDir(io, "next", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
    defer next.close(io);
    var previous = txn.openDir(io, "previous", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
    defer previous.close(io);

    // No worktree data is touched until both the desired and rollback payloads
    // are verified and staged in this same-filesystem transaction dir, from
    // which the commit renames them into place. Not fsync'd: the tree's
    // crash image is the undo journal's to guarantee, not the stage's.
    try stagePlan(gpa, io, log, next, &target);
    try stagePlan(gpa, io, log, previous, &old);
    try preflight(gpa, io, worktree, &target, &old_managed);
    var created_directories = try CreatedDirectories.init(gpa, &target);
    defer created_directories.deinit(gpa);

    // Journal the undo image (B-undo-3/14): the pre-mutation bytes of every path
    // this update writes or deletes — `old_managed ∪ target` — plus the current
    // `.zit-checkout`/`.zit-managed`. Written and fsynced HERE, before
    // `commitMaterialization` moves the first working-file byte. Placed after
    // `requireClean`/`preflight`, so a rejected update never journals; the
    // rollback branch below clears it so a cleanly-undone update leaves no level.
    try recordUndoImage(gpa, io, worktree, &old, &target);

    commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{
        .version = target_id,
        .branch = branch,
        // `update` never carries a pending merge (that is `merge`'s job); a
        // plain check-out always clears it — and never a pending special merge.
        .merge_parent = "",
        .special_op = "",
        .manifest_flags = manifest_stored,
    }, .{ .log = log, .target_id = target_id, .tags = facts.tags }, cone_section_update) catch |err| {
        rollbackMaterialization(gpa, io, worktree, previous, &target, &old, &old_managed, &created_directories) catch {
            // The staged `previous/` tree is the only complete recovery copy.
            // Retain it for recovery rather than letting the deferred cleanup
            // destroy it after a failed inverse. The undo journal is retained
            // too: it is the crash-recovery image for the half-applied tree.
            transaction_live = false;
            return error.RollbackFailed;
        };
        // Clean rollback: the tree is back to pre-mutation, so there is nothing
        // to undo — drop the journal (keeps B-undo-9 honest).
        journal.clear(io, worktree);
        return err;
    };

    // Sparse-cone RFC section 5.4: on a store that is ALREADY sparse, a
    // `.set` cone request narrows (or widens) store policy alongside the
    // worktree -- one flag, one intent. On a dense store the request stays
    // worktree-only: creating store policy here would silently make
    // `pack --drop-elided` eligible to reclaim, an escalation only the
    // fetch paths (which the operator sees) and clone --cone may perform.
    // Best-effort and post-commit-point, like the vstat sidecar below.
    if (sparse == .set) {
        var existing = repocache.readStoreCone(gpa, io, worktree);
        if (existing) |*c| {
            c.deinit(gpa);
            repocache.writeStoreCone(gpa, io, worktree, switch (sparse) {
                .set => |req| req,
                .inherit => unreachable,
            });
        }
    }

    // The tree and record are committed; remember what was just written so
    // the next clean check can stat instead of reconstruct. After the commit
    // point deliberately: a sidecar describing a tree that failed to commit
    // would be a lie, while a missing sidecar is only slowness.
    VStat.writeForPlan(gpa, io, worktree, &target, target_id);

    // `.zit-checkout` is the commit point. A post-commit cleanup error is
    // reported but must never roll the committed tree or metadata back.
    transaction_live = false;
    if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed;

    worktree.deleteTree(io, txn_name) catch return error.CleanupFailed;
}

/// Build the affected-path set (`old_managed ∪ target`, deduplicated by a
/// throwaway `ManagedSet`) and hand it to the undo journal (B-undo-14: exactly
/// one seam per mutator). The set unifies both directions of the mutation — a
/// path the update writes, a path it deletes — so `zit undo` restores every one.
fn recordUndoImage(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    old: *const Plan,
    target: *const Plan,
) ApplyError!void {
    var affected = managed.ManagedSet.init(gpa);
    defer affected.deinit();
    // The OLD PLAN stands in for the old managed set: `requireClean` proved
    // their memberships identical before this runs, and the plan carries the
    // unchanged marks the set cannot. An unchanged path is neither written
    // nor deleted by this update, so B-undo-3's "every path this update
    // writes or deletes" excludes it by its own words.
    for (old.entries.items) |entry| {
        if (entry.unchanged) continue;
        _ = affected.insert(entry.path) catch |err| return mapManagedError(err);
    }
    for (target.entries.items) |entry| {
        if (entry.unchanged) continue;
        _ = affected.insert(entry.path) catch |err| return mapManagedError(err);
    }

    // The affected set is the union of both plans' CHANGED entries, journaled
    // before the first working byte moves (B-undo-3/14). It can therefore never
    // be larger than either plan, and never smaller than the target's changed
    // count -- the direction the durability rule actually needs.
    std.debug.assert(affected.iterate().len <= old.entries.items.len + target.entries.items.len);

    journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        // A journal device fault must abort BEFORE any working file moves: the
        // durability rule forbids mutating without a recoverable pre-image.
        error.Storage, error.Corrupt, error.Empty => return error.Storage,
    };
}

/// Apply the caller's carries to the target plan (what materializes) and the
/// baseline plan (what a failed update restores), keeping `desired_managed` in
/// step with the target's membership.
///
/// Every touched entry loses its `unchanged` mark: that mark means "the file
/// already on disk is correct", which is decided by comparing the two CHECK-INS
/// and is exactly the assumption a carry contradicts.
fn overlayCarry(
    gpa: std.mem.Allocator,
    target: *Plan,
    old: *Plan,
    carry: []const Carry,
    desired_managed: *managed.ManagedSet,
) ApplyError!void {
    for (carry) |item| {
        try validateWorktreePath(item.path);
        if (item.after) |after| {
            try target.setCarried(gpa, item.path, after);
            _ = desired_managed.insert(item.path) catch |err| return mapManagedError(err);
        } else {
            target.removePath(gpa, item.path);
            _ = desired_managed.remove(item.path);
        }
        if (item.before) |before| {
            try old.setCarried(gpa, item.path, before);
        } else {
            old.removePath(gpa, item.path);
        }
    }
}

fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, sparse: ?*const cone_mod.Cone) ApplyError!Plan {
    if (!artifact.validateName(target_id)) return error.InvalidCheckinId;

    var target_digest: [logstore.max_hash_len]u8 = undefined;
    const raw_target = try decodeValidatedName(target_id, &target_digest);
    const target_rid = log.lookup(raw_target) orelse return error.CheckinNotFound;
    const target_bytes = log.content(gpa, target_rid) catch |err| return mapContentError(err);
    if (target_bytes == null) return error.CheckinContentMissing;

    var target = LoadedManifest{
        .bytes = target_bytes.?,
        .manifest = undefined,
    };
    {
        errdefer gpa.free(target.bytes);
        if (!artifact.verify(target_id, target.bytes)) return error.CheckinNameMismatch;
        // Read path: verify identity over the original bytes, then tolerate the
        // legacy F-card trailing separator when parsing fossil-origin history.
        const im = artifact.parseForImport(gpa, target.bytes) catch |err| return mapTargetParseError(err);
        target.manifest = im.manifest;
        target.normalized = im.normalized;
    }
    defer target.deinit(gpa);
    if (target.manifest.kind != .manifest) return error.TargetNotManifest;

    // One memo across BOTH flattens below. Target and baseline share every node
    // the check-in did not touch, so the baseline walk answers from here instead
    // of re-reading the store. Owned by the plan build: it dies with this call,
    // caching nothing across invocations.
    var sub_memo: ?repostate.SubManifestMemo = null;
    defer if (sub_memo) |*m| m.deinit();

    // submanifests §5.1: a tree check-in's file set lives behind its S cards, so
    // walk it into a flat card list before §8 ever looks. §4.4 makes this
    // exclusive with the B-card branch below (never both B and S), so a check-in
    // is walked or resolved, never both.
    try flattenIfTree(gpa, log, &target.manifest, &sub_memo);

    var baseline: ?LoadedManifest = null;
    defer if (baseline) |*item| item.deinit(gpa);
    var baseline_context = BaselineContext{};
    if (target.manifest.b) |b| {
        var digest: [logstore.max_hash_len]u8 = undefined;
        const raw = decodeValidatedName(b.hash, &digest) catch return error.BaselineNotFound;
        const rid = log.lookup(raw) orelse return error.BaselineNotFound;
        const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
        if (bytes == null) return error.BaselineContentMissing;
        var item = LoadedManifest{ .bytes = bytes.?, .manifest = undefined };
        {
            errdefer gpa.free(item.bytes);
            if (!artifact.verify(b.hash, item.bytes)) return error.BaselineNameMismatch;
            const im = artifact.parseForImport(gpa, item.bytes) catch |err| return mapBaselineParseError(err);
            item.manifest = im.manifest;
            item.normalized = im.normalized;
        }
        if (item.manifest.kind != .manifest) {
            item.deinit(gpa);
            return error.BaselineNotManifest;
        }
        baseline = item;
        // submanifests §5.4: "a delta manifest MAY name a tree manifest in its B
        // card ... the baseline cursor of the merge-walk ... is the enumeration
        // of Section 5.1." Flattening here makes that composition free.
        try flattenIfTree(gpa, log, &baseline.?.manifest, &sub_memo);
        baseline_context.expected_hash = b.hash;
        baseline_context.manifest = &baseline.?.manifest;
    }

    const target_files = repository.manifestFilesOf(&target.manifest);
    var walk = artifact.enumerate(target_files, .{
        .ctx = @ptrCast(&baseline_context),
        .resolveFn = resolveBaseline,
    }) catch |err| switch (err) {
        error.BaselineUnresolvable => return error.BaselineNotFound,
    };

    // Sparse-cone RFC section 4.1: the cone lands here, at the seam, once --
    // every plan consumer inherits the filter and no call site threads it.
    walk.sparse = sparse;

    var result = Plan{};
    errdefer result.deinit(gpa);
    if (target.manifest.r) |r| {
        std.debug.assert(r.checksum.len == 32); // §4.7.1: the parser admits exactly 32 hex
        var r_owned: [32]u8 = undefined;
        @memcpy(&r_owned, r.checksum[0..32]);
        result.r_checksum = r_owned;
    }
    while (walk.next()) |file| {
        if (file.hash == null) continue;
        try result.add(gpa, file);
    }
    try result.validateTopology();
    return result;
}

/// §6.3 the checkout-side R verification, Fossil's default repo-cksum
/// behavior: when the target carries an R card, recompute the aggregate over
/// the PLAN's enumeration from the log's own blobs and refuse a mismatch
/// before anything is materialized. Entries whose F run omitted a hash never
/// enter the plan, matching §6.3.2's enumeration of the effective set. A
/// manifest without R (repo-cksum off at its writer) verifies nothing —
/// absence is legal, disagreement is not. One full content pass over the
/// plan; the price Fossil's default pays per checkout, paid here in the one
/// verb family that materializes a check-in.
fn verifyPlanAggregate(gpa: std.mem.Allocator, log: *logstore.LogStore, plan: *const Plan) ApplyError!void {
    const want = plan.r_checksum orelse return;
    var agg: artifact.ChecksumAggregator = .{};
    for (plan.entries.items) |entry| {
        // A blob that cannot be read is not R's finding: the checksum claim is
        // "when the content is all present, it must agree". Abstain and let
        // the staging pass name the missing/damaged file precisely.
        const rid = log.lookup(entry.digestSlice()) orelse return;
        const bytes = (log.content(gpa, rid) catch |err| return mapContentError(err)) orelse return;
        defer gpa.free(bytes);
        agg.update(entry.path, bytes);
    }
    var got: [32]u8 = undefined;
    _ = agg.final(&got);
    if (!std.mem.eql(u8, &got, &want)) return error.AggregateChecksumMismatch;
}

/// submanifests §5.1/§5.4: derive `manifest`'s complete file set when it is a
/// tree manifest, leaving a flat or delta manifest untouched. Runs before the §8
/// enumeration below, so `artifact.enumerate` and the merge walk stay unchanged
/// (§5.4: "the merge-walk itself is unchanged").
///
/// The §5.2 resolve-verify-parse walk itself is NOT reimplemented here.
/// `Repository.flattenTreeFromLog` already takes an already-open LogStore —
/// precisely what this module holds — so update reaches the same resolver
/// `commit` writes through, and §5.2 has exactly one implementation in the tree.
/// That matters beyond deduplication: a second copy drifted once already, over
/// the §3.5 rule that an S card's hash is case-insensitive. The shared resolver
/// decodes the token to raw digest bytes and compares those, so an uppercase
/// spelling names the same artifact — which is what §3.5 says it is.
///
/// Every §5.1 fault becomes `BaselineNotFound`, the code this file already uses
/// for §8.3's `BaselineUnresolvable`. §5.2 puts S resolution "under the same two
/// disciplines" as the B card and mandates hard failure without mandating a
/// distinct code; the five-way `TreeError` distinction the conformance vectors
/// test lives at the artifact layer, where it is observable. The store faults
/// stay distinct: the mapping below is the exact inverse of the one
/// `Repository`'s log source applies, so no `ApplyError` consumer loses a
/// distinction it could branch on.
/// `slot` is a caller-owned memo shared by the TARGET and BASELINE flattens of
/// one plan. §5.4 lets a delta manifest name a tree manifest in its B card, so
/// those two walks run over trees that differ only where the check-in changed —
/// which on a wide repository is a handful of paths out of tens of thousands.
/// Without the memo each walk re-inflates and re-parses every shared node from
/// the store, and the second one learns nothing from the first.
///
/// This does NOT make the cost proportional to what changed: both walks still
/// enumerate the whole tree. It removes the redundant PARSE, not the redundant
/// WALK. Pruning the walk needs the lockstep descent `pack.zig` uses, which is
/// a larger change.
fn flattenIfTree(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    manifest: *artifact.Manifest,
    slot: *?repostate.SubManifestMemo,
) ApplyError!void {
    if (manifest.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one

    repository.Repository.flattenTreeFromLogMemo(gpa, log, manifest, slot) catch |err| return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.Corrupt => error.Corrupt,
        // The log source renames the LogStore's `Storage` on the way out; rename
        // it back so a store fault still reads as one here.
        error.CannotRead => error.Storage,
        // artifact.TreeError, listed rather than caught by `else` so a new §5.1
        // fault code has to be triaged here instead of silently collapsing.
        error.SubUnresolvable,
        error.DepthExceeded,
        error.ReferenceCycle,
        error.FirstFilenameMismatch,
        error.OrderViolation,
        => error.BaselineNotFound, // §5.2 hard failure
    };
    std.debug.assert(manifest.flattened != null); // §5.2: never a partial file set
}

fn resolveBaseline(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
    const baseline: *const BaselineContext = @ptrCast(@alignCast(context));
    const expected = baseline.expected_hash orelse return null;
    const manifest = baseline.manifest orelse return null;
    if (!std.mem.eql(u8, expected, hash)) return null;
    return repository.manifestFilesOf(manifest);
}

/// How one path's working-tree state differs from the check-in the checkout
/// names. Only divergent paths get one; a clean path is absent from the list.
pub const DivergenceKind = enum {
    /// Managed and present in the check-in, but the bytes (or mode) on disk
    /// differ from it.
    modified,
    /// Managed and present in the check-in, but absent from the working tree.
    missing,
    /// Managed but NOT in the check-in — `zit add` since the materialization.
    added,
    /// In the check-in but no longer managed — `zit rm` since.
    removed,
};

/// One divergent path. `path` borrows the managed set / plan that produced it,
/// both of which die with the scan, so `scanWorktree` dupes onto its own arena.
pub const Divergence = struct {
    path: []const u8,
    kind: DivergenceKind,
};

/// How a carrying `update` decides what to keep. `applyCheckinWith` scans the
/// working tree ONCE and, only if it finds divergence, hands the whole list
/// here; the answer is the carries to overlay.
///
/// A callback rather than a plain `[]const Carry` parameter because the caller
/// cannot compute carries without knowing the divergences, and it cannot learn
/// the divergences without a scan — so passing them in would mean scanning the
/// tree twice for every update, including the clean ones that never carry
/// anything. Measured on a 20k-file tree, that doubled scan cost a clean no-op
/// update 63% (0.38s -> 0.62s). Inverting the call keeps it at one scan.
///
/// The policy (three-way merging each divergent path) lives with the caller,
/// which has the repository and the merge engine; the scan and the transaction
/// live here. Neither half needs the other's machinery.
pub const CarryResolver = struct {
    ctx: *anyopaque,
    /// `divergences` is ascending by path and borrows storage that dies when
    /// this call returns — copy anything that must outlive it. The returned
    /// carries must outlive the whole `applyCheckinWith` call.
    ///
    /// `error.Refused` means "these changes cannot be carried"; the caller has
    /// already reported which and why, and the update aborts before mutating
    /// anything.
    resolve: *const fn (ctx: *anyopaque, divergences: []const Divergence) error{ OutOfMemory, Refused }![]const Carry,
};

/// The divergent paths of the working tree against the check-in it is on,
/// ascending by path, duped onto `arena`. Empty means clean — exactly the
/// condition `requireCleanTree` demands.
///
/// `update` does not use this: it carries content across a materialization it
/// already scans for, so it takes its divergences from that one scan via
/// `CarryResolver`. `merge` does not materialize a check-in — it applies an
/// outcome set through `applyMerge` — so it has no scan of its own to piggyback
/// on and asks here instead.
pub fn scanWorktree(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
) ApplyError![]const Divergence {
    var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer current_managed.deinit();
    var found: std.ArrayList(Divergence) = .empty;
    defer found.deinit(gpa);
    // The recorded paths BORROW the plan and the managed set, so they are duped
    // onto the arena before either dies — not after.
    var plan = try scanTree(gpa, io, worktree, log, &current_managed, &found);
    defer plan.deinit(gpa);
    const out = arena.alloc(Divergence, found.items.len) catch return error.OutOfMemory;
    for (found.items, out) |src, *dst| {
        dst.* = .{
            .path = arena.dupe(u8, src.path) catch return error.OutOfMemory,
            .kind = src.kind,
        };
    }
    return out;
}

fn requireClean(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    current_managed: *const managed.ManagedSet,
) ApplyError!Plan {
    return scanTree(gpa, io, worktree, log, current_managed, null);
}

/// The one worktree-vs-check-in comparison, in both of its modes. `found` null
/// is the historical `requireClean`: the FIRST divergence returns
/// `WorktreeNotClean` and no plan. `found` non-null records every divergence
/// and returns the baseline plan regardless, for a caller that intends to merge
/// the differences rather than refuse them.
///
/// Deliberately one function and not two: the modes must agree about what
/// "clean" means down to the last stat, or `update` would refuse a tree that
/// the merge path considers unchanged (or worse, the reverse).
fn scanTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    current_managed: *const managed.ManagedSet,
    found: ?*std.ArrayList(Divergence),
) ApplyError!Plan {
    for (current_managed.iterate()) |path| {
        validateWorktreePath(path) catch return error.CurrentStateInvalid;
    }

    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const current = checkout.read(io, worktree, &checkout_buf) catch |err| return mapCheckoutError(err);

    // The cone that produced the CURRENT materialization and managed set.
    // Deriving it here (not taking it as a parameter) keeps every caller --
    // update, merge, revert -- incapable of comparing an in-cone managed set
    // against a dense plan, the mismatch that reports every out-of-cone file
    // as missing (sparse-cone RFC section 4.1).
    var stored_cone = try readStoredCone(gpa, io, worktree);
    defer if (stored_cone) |*c| c.deinit(gpa);

    // An UNBORN record (`version == ""`) names no check-in, so there is no plan
    // to build against — the same situation as an absent record, and handled by
    // the same arm so the two cannot drift apart. Unborn is the normal, expected
    // state of every fresh repository: `init` writes it unconditionally (and
    // `clone` delegates repo creation to `init`), so without this the first
    // `update` after a clone would hand the empty version to `buildPlan`, fail
    // `artifact.validateName("")`, and report `CurrentStateInvalid` — a clean,
    // correct tree diagnosed as broken.
    if (current == null or current.?.version.len == 0) {
        if (current_managed.iterate().len != 0) {
            // Every managed path is an `added`: an unborn checkout names no
            // check-in, so nothing on disk can match one.
            if (found) |list| {
                for (current_managed.iterate()) |path| {
                    list.append(gpa, .{ .path = path, .kind = .added }) catch return error.OutOfMemory;
                }
            } else return error.WorktreeNotClean;
        }
        // A record that names no check-in yet claims a merge parent or a
        // pending special operation is incoherent, not a fresh clone: both
        // describe work in progress against a check-in that the record does not
        // name. Only a present record can carry them.
        if (current) |c| {
            if (c.merge_parent.len != 0 or c.special_op.len != 0) return error.CurrentStateInvalid;
        }
        return Plan{};
    }

    var vstat = VStat.read(gpa, io, worktree);
    defer vstat.deinit(gpa);

    // The sidecar-as-plan fast path: when the sidecar describes exactly the
    // current check-in AND its membership agrees with the authoritative
    // managed set, the baseline plan is reconstructed with no tree flatten at
    // all -- removing one of the two O(width) walks that made `update` cost
    // the same regardless of how little changed. A sidecar that fails either
    // test is discarded (not refused): staleness costs a flatten, never an
    // error, and every entry is still individually verified below before
    // anything acts on it.
    var plan = blk: {
        if (vstat.planFor(gpa, current.?.version)) |candidate_const| {
            var candidate = candidate_const;
            if (sameMembership(&candidate, current_managed)) break :blk candidate;
            candidate.deinit(gpa);
        }
        break :blk buildPlan(gpa, log, current.?.version, if (stored_cone) |*c| c else null) catch |err| switch (err) {
            error.OutOfMemory, error.Storage, error.Corrupt => return err,
            else => return error.CurrentStateInvalid,
        };
    };
    errdefer plan.deinit(gpa);

    const membership_drifted = !sameMembership(&plan, current_managed);
    if (membership_drifted) {
        if (found) |list| {
            try recordMembershipDrift(gpa, list, &plan, current_managed);
        } else return error.WorktreeNotClean;
    }
    for (plan.entries.items) |*entry| {
        // A path the check-in has but the checkout no longer manages was
        // already recorded as `removed` above; its on-disk state is not this
        // check-in's business, so it is not content-compared. Guarded on the
        // drift flag so the overwhelmingly common no-drift scan does not pay a
        // set lookup per path — this loop runs once per file in the check-in.
        if (membership_drifted and !current_managed.contains(entry.path)) continue;
        // Stat-first: a sidecar row carrying THIS entry's digest whose inode,
        // size and mtime still match the file proves it unmodified since the
        // materialization that wrote both. Everything else takes the full
        // content compare, which stays the authority.
        if (entry.mode != .symlink) {
            const existing = try statExisting(io, worktree, entry.path);
            switch (existing) {
                .missing => {
                    if (found) |list| {
                        list.append(gpa, .{ .path = entry.path, .kind = .missing }) catch return error.OutOfMemory;
                        continue;
                    }
                    return error.WorktreeNotClean;
                },
                .stat => |stat| if (modeMatches(stat, entry.mode) and vstat.proves(entry, stat)) continue,
            }
        }
        if (!try pathMatchesEntry(gpa, io, worktree, log, entry)) {
            if (found) |list| {
                list.append(gpa, .{ .path = entry.path, .kind = .modified }) catch return error.OutOfMemory;
                continue;
            }
            return error.WorktreeNotClean;
        }
    }
    // Membership drift is recorded in one pass and content drift in another, so
    // the two arrive grouped rather than interleaved. Sorting here makes
    // "ascending by path" a property of the RESULT rather than of the order the
    // passes happened to run in — which is what a caller reporting a path list
    // depends on.
    if (found) |list| {
        std.mem.sort(Divergence, list.items, {}, struct {
            fn lessThan(_: void, a: Divergence, b: Divergence) bool {
                return std.mem.order(u8, a.path, b.path) == .lt;
            }
        }.lessThan);
    }
    // Postcondition: a returned plan is the current check-in's. In refusing
    // mode its membership also equals the recorded managed set — the guard
    // above rejected any drift — so the record and the managed set agree on
    // every success path a caller of `requireClean` can reach. In collecting
    // mode the drift is the answer, not a fault, so it is reported instead.
    std.debug.assert(found != null or sameMembership(&plan, current_managed));
    return plan;
}

/// The membership half of a dirty scan: paths the checkout manages that the
/// check-in does not (`added`), and paths the check-in has that the checkout no
/// longer manages (`removed`). Both lists are sorted and unique — the plan's
/// order is `Plan.validateTopology`'s, the managed set's is its own — so this
/// is one merge walk, not a pair of nested scans.
fn recordMembershipDrift(
    gpa: std.mem.Allocator,
    list: *std.ArrayList(Divergence),
    plan: *const Plan,
    current_managed: *const managed.ManagedSet,
) ApplyError!void {
    const paths = current_managed.iterate();
    var i: usize = 0; // into plan.entries (the check-in's set)
    var j: usize = 0; // into paths (the managed set)
    while (i < plan.entries.items.len or j < paths.len) {
        const in_plan = if (i < plan.entries.items.len) plan.entries.items[i].path else null;
        const in_managed = if (j < paths.len) paths[j] else null;
        const order: std.math.Order = if (in_plan == null)
            .gt
        else if (in_managed == null)
            .lt
        else
            std.mem.order(u8, in_plan.?, in_managed.?);
        switch (order) {
            .eq => {
                i += 1;
                j += 1;
            },
            .lt => {
                list.append(gpa, .{ .path = in_plan.?, .kind = .removed }) catch return error.OutOfMemory;
                i += 1;
            },
            .gt => {
                list.append(gpa, .{ .path = in_managed.?, .kind = .added }) catch return error.OutOfMemory;
                j += 1;
            },
        }
    }
}

fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool {
    const paths = current.iterate();
    if (paths.len != plan.entries.items.len) return false;
    for (paths, plan.entries.items) |path, entry| {
        if (!std.mem.eql(u8, path, entry.path)) return false;
    }
    return true;
}

/// The stat sidecar: `(path, inode, size, mtime, digest)` for every file the
/// last materialization wrote, so the NEXT clean check can prove "unmodified
/// since we wrote it" with one stat instead of reconstructing the file's
/// plaintext out of the log. On a store with deep per-file delta chains that
/// reconstruction dominated `update` outright: a no-op update measured ~109s
/// of pure CPU, all of it `requireClean` re-deriving content that had not
/// moved.
///
/// Advisory, never authoritative. A row is trusted only when its digest is
/// the digest the CURRENT baseline expects AND inode, size and mtime all
/// still match the file on disk; anything else -- absent file, absent row,
/// stale digest, any stat drift, unreadable or malformed sidecar -- falls
/// back to the full content compare below, which remains the authority. The
/// accepted trade is fossil's own vfile trade: an edit that restores mtime,
/// size AND inode exactly is invisible to the stat check.
const VStat = struct {
    /// A member of the advisory `.zit-cache/` directory, beside repocache's
    /// `repo`. One root name for everything disposable.
    const filename = repocache.cache_dirname ++ "/vstat";
    const magic = "zitvstat";
    /// v2: the sidecar carries the check-in it describes plus each entry's
    /// mode, a whole-file CRC, and PLAN ORDER -- which together make it the
    /// BASELINE PLAN, not merely a stat memo. A v1 file fails the version
    /// check, reads as empty, and costs one flatten to rebuild.
    const version: u16 = 2;

    const Row = struct {
        inode: u64,
        size: u64,
        mtime_ns: i96,
        digest: [logstore.max_hash_len]u8,
        digest_len: u8,
        mode: artifact.FileMode,
    };
    const Entry = struct {
        path: []const u8,
        row: Row,
    };

    /// The check-in whose materialization wrote these rows. The rows are only
    /// meaningful AS A PLAN for exactly this version; `planFor` refuses any
    /// other, and staleness therefore costs a flatten rather than an error.
    checkin: []const u8 = &.{},
    /// In plan order -- `artifact.enumerate` emits paths sorted, `writeForPlan`
    /// preserves that, and `validateTopology` depends on it.
    entries: std.ArrayList(Entry) = .empty,
    index: std.StringHashMapUnmanaged(usize) = .empty,

    fn appendLittle(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, value: T) error{OutOfMemory}!void {
        var buf: [@divExact(@typeInfo(T).int.bits, 8)]u8 = undefined;
        std.mem.writeInt(T, &buf, value, .little);
        try out.appendSlice(gpa, &buf);
    }

    fn deinit(self: *VStat, gpa: std.mem.Allocator) void {
        if (self.checkin.len != 0) gpa.free(self.checkin);
        for (self.entries.items) |entry| gpa.free(entry.path);
        self.entries.deinit(gpa);
        self.index.deinit(gpa);
        self.* = undefined;
    }

    /// Absent, unreadable, malformed, torn, or wrong-version reads as EMPTY --
    /// the sidecar can only ever remove work, never add a failure mode.
    fn read(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) VStat {
        var self: VStat = .{};
        var file = worktree.openFile(io, filename, .{}) catch return self;
        defer file.close(io);
        const st = file.stat(io) catch return self;
        const size: usize = std.math.cast(usize, st.size) orelse return self;
        const buf = gpa.alloc(u8, size) catch return self;
        defer gpa.free(buf);
        const n = file.readPositionalAll(io, buf, 0) catch return self;
        self.parse(gpa, buf[0..n]) catch {
            self.deinit(gpa);
            self = .{};
        };
        return self;
    }

    fn parse(self: *VStat, gpa: std.mem.Allocator, bytes: []const u8) !void {
        const crc_len = 4;
        if (bytes.len < magic.len + 4 + crc_len) return error.Corrupt;
        // Whole-file CRC first: a torn write must read as absence, never as a
        // shorter-but-plausible plan.
        const stored_crc = std.mem.readInt(u32, bytes[bytes.len - crc_len ..][0..4], .little);
        var crc = crc32.Crc32.init();
        crc.update(bytes[0 .. bytes.len - crc_len]);
        if (crc.final() != stored_crc) return error.Corrupt;
        const body = bytes[0 .. bytes.len - crc_len];

        var pos: usize = 0;
        if (!std.mem.eql(u8, body[0..magic.len], magic)) return error.Corrupt;
        pos = magic.len;
        if (std.mem.readInt(u16, body[pos..][0..2], .little) != version) return error.Corrupt;
        pos += 2;
        const checkin_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little);
        pos += 2;
        if (body.len - pos < checkin_len) return error.Corrupt;
        self.checkin = try gpa.dupe(u8, body[pos..][0..checkin_len]);
        pos += checkin_len;
        while (pos < body.len) {
            if (body.len - pos < 2) return error.Corrupt;
            const path_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little);
            pos += 2;
            const fixed = 8 + 8 + 12 + 1;
            if (body.len - pos < path_len + fixed) return error.Corrupt;
            const path = body[pos..][0..path_len];
            pos += path_len;
            var row: Row = undefined;
            row.inode = std.mem.readInt(u64, body[pos..][0..8], .little);
            pos += 8;
            row.size = std.mem.readInt(u64, body[pos..][0..8], .little);
            pos += 8;
            row.mtime_ns = @bitCast(std.mem.readInt(u96, body[pos..][0..12], .little));
            pos += 12;
            row.digest_len = body[pos];
            pos += 1;
            if (row.digest_len > logstore.max_hash_len or body.len - pos < row.digest_len + 1) return error.Corrupt;
            @memcpy(row.digest[0..row.digest_len], body[pos..][0..row.digest_len]);
            pos += row.digest_len;
            const mode_byte = body[pos];
            pos += 1;
            if (mode_byte > @backingInt(artifact.FileMode.symlink)) return error.Corrupt;
            row.mode = @fromBackingInt(@intCast(mode_byte));
            const owned = try gpa.dupe(u8, path);
            errdefer gpa.free(owned);
            const gop = try self.index.getOrPut(gpa, owned);
            if (gop.found_existing) {
                gpa.free(owned);
                return error.Corrupt; // a duplicate path is not a record this writer produces
            }
            gop.value_ptr.* = self.entries.items.len;
            try self.entries.append(gpa, .{ .path = owned, .row = row });
        }
    }

    /// Whether `entry`'s file can be proven unmodified by stat alone.
    fn proves(self: *const VStat, entry: *const PlanEntry, stat: std.Io.File.Stat) bool {
        const at = self.index.get(entry.path) orelse return false;
        const row = self.entries.items[at].row;
        if (row.digest_len != entry.digest_len) return false;
        if (!std.mem.eql(u8, row.digest[0..row.digest_len], entry.digestSlice())) return false;
        if (row.inode != @as(u64, @bitCast(stat.inode))) return false;
        if (row.size != stat.size) return false;
        return row.mtime_ns == stat.mtime.nanoseconds;
    }

    /// The BASELINE PLAN, reconstructed with no flatten, when and only when
    /// this sidecar describes exactly `checkin_id`. This is the step that
    /// removes one of `update`'s two O(width) tree walks: the rows were
    /// serialized from the plan the last materialization ran, in plan order,
    /// under a whole-file CRC -- the same trust `.zit-managed` gets, and less
    /// than `.zit-cache` gets, since every entry is still individually
    /// verified (stat-first, content on any doubt) by `requireClean` before
    /// anything acts on it. Null on any shortfall; the caller flattens.
    fn planFor(self: *const VStat, gpa: std.mem.Allocator, checkin_id: []const u8) ?Plan {
        if (self.checkin.len == 0 or !std.mem.eql(u8, self.checkin, checkin_id)) return null;
        var plan = Plan{};
        var ok = false;
        defer if (!ok) plan.deinit(gpa);
        for (self.entries.items) |entry| {
            validateWorktreePath(entry.path) catch return null;
            var name_buf: [64]u8 = undefined;
            const name = encodeName(entry.row.digest[0..entry.row.digest_len], &name_buf);
            const path = gpa.dupe(u8, entry.path) catch return null;
            const owned_name = gpa.dupe(u8, name) catch {
                gpa.free(path);
                return null;
            };
            plan.entries.append(gpa, .{
                .path = path,
                .name = owned_name,
                .digest = entry.row.digest,
                .digest_len = entry.row.digest_len,
                .mode = entry.row.mode,
            }) catch {
                gpa.free(path);
                gpa.free(owned_name);
                return null;
            };
        }
        plan.validateTopology() catch return null;
        ok = true;
        return plan;
    }

    /// Stat every plan entry and write the sidecar, atomically, mirroring the
    /// managed set's writer. Best-effort at every step -- EXCEPT that a file
    /// that cannot be stat'ed abandons the write entirely: v2's rows are a
    /// plan, and a plan missing an entry would reconstruct a baseline missing
    /// a file, where v1's per-row omission only cost a content compare.
    fn writeForPlan(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, plan: *const Plan, checkin_id: []const u8) void {
        if (checkin_id.len == 0 or checkin_id.len > std.math.maxInt(u16)) return;
        var out: std.ArrayList(u8) = .empty;
        defer out.deinit(gpa);
        out.appendSlice(gpa, magic) catch return;
        appendLittle(gpa, &out, u16, version) catch return;
        appendLittle(gpa, &out, u16, @intCast(checkin_id.len)) catch return;
        out.appendSlice(gpa, checkin_id) catch return;
        var parents: ParentCache = .{};
        defer parents.deinit(io);
        // A carried path holds the working tree's bytes, which the plan's
        // digest does not describe: the row would prove the file "unmodified
        // since we wrote it" to the next `scanTree` and hide a real local edit.
        // Omitting just that row is not the alternative — by the rule above, a
        // plan missing an entry reconstructs a baseline missing a file, and
        // that baseline is what `stagePlan` writes as the rollback image. So a
        // carried plan abandons the sidecar wholesale, exactly as an
        // unstat-able path does. The cost is one flatten on the next update.
        for (plan.entries.items) |entry| {
            if (entry.carry != null) return;
        }
        for (plan.entries.items) |entry| {
            if (entry.path.len > std.math.maxInt(u16)) return;
            const existing = statExistingCached(&parents, io, worktree, entry.path) catch return;
            const stat = switch (existing) {
                .missing => return,
                .stat => |value| value,
            };
            appendLittle(gpa, &out, u16, @intCast(entry.path.len)) catch return;
            out.appendSlice(gpa, entry.path) catch return;
            appendLittle(gpa, &out, u64, @bitCast(stat.inode)) catch return;
            appendLittle(gpa, &out, u64, stat.size) catch return;
            appendLittle(gpa, &out, u96, @bitCast(stat.mtime.nanoseconds)) catch return;
            appendLittle(gpa, &out, u8, entry.digest_len) catch return;
            out.appendSlice(gpa, entry.digestSlice()) catch return;
            appendLittle(gpa, &out, u8, @backingInt(entry.mode)) catch return;
        }
        var crc = crc32.Crc32.init();
        crc.update(out.items);
        appendLittle(gpa, &out, u32, crc.final()) catch return;
        if (!repocache.ensureCacheDir(io, worktree)) return;
        var af = worktree.createFileAtomic(io, filename, .{ .replace = true }) catch return;
        defer af.deinit(io);
        af.file.writePositionalAll(io, out.items, 0) catch return;
        af.file.sync(io) catch return;
        af.replace(io) catch return;
    }
};

fn pathMatchesEntry(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    entry: *const PlanEntry,
) ApplyError!bool {
    const existing = try statExisting(io, worktree, entry.path);
    const stat = switch (existing) {
        .missing => return false,
        .stat => |value| value,
    };
    switch (entry.mode) {
        .symlink => if (stat.kind != .sym_link) return false,
        .regular, .executable => if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false,
    }

    const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid;
    const expected = log.content(gpa, rid) catch |err| return mapContentError(err);
    if (expected == null) return error.CurrentStateInvalid;
    defer gpa.free(expected.?);
    if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid;

    const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
        error.PathMissing => return false,
        else => return @errorCast(err),
    };
    defer parent.close(io);
    switch (entry.mode) {
        .symlink => {
            const actual = gpa.alloc(u8, std.math.add(usize, expected.?.len, 1) catch return error.OutOfMemory) catch return error.OutOfMemory;
            defer gpa.free(actual);
            const n = parent.dir.readLink(io, parent.name, actual) catch return false;
            return n == expected.?.len and std.mem.eql(u8, actual[0..n], expected.?);
        },
        .regular, .executable => {
            if (stat.size != expected.?.len) return false;
            var file = parent.dir.openFile(io, parent.name, .{
                .allow_directory = false,
                .follow_symlinks = false,
                .resolve_beneath = true,
            }) catch return false;
            defer file.close(io);
            const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory;
            defer gpa.free(actual);
            const n = file.readPositionalAll(io, actual, 0) catch return error.Storage;
            return n == actual.len and std.mem.eql(u8, actual, expected.?);
        },
    }
}

/// What one materialization needs out of the derived repository state: the
/// branch the record commits, and — only when the `t` selector asked for it —
/// the check-in's symbolic tag names. Both come from ONE state derive; a second
/// pass just for the tags would double the most expensive read on the path.
const TargetFacts = struct {
    branch: []u8,
    /// One symbolic tag name per line, newline-terminated; empty when `t` was
    /// not requested (and when the check-in carries no symbolic tag).
    tags: []u8,

    fn deinit(self: *TargetFacts, gpa: std.mem.Allocator) void {
        gpa.free(self.branch);
        gpa.free(self.tags);
        self.* = undefined;
    }
};

/// The cached-view fast path for the facts below: the same questions answered
/// from the `.zit-cache`-backed `Repository` view instead of `assembleState`'s
/// from-scratch sweep over every artifact in the store. On a warm cache this
/// is milliseconds where the sweep was tens of seconds, and the answers are
/// the same by the cache's own contract -- the view it serves is proven
/// equivalent to a cold derive. Null on ANY shortfall (no cache, open failure,
/// target not in the view), and the caller falls back to the sweep, which
/// stays the authority.
fn cachedTargetFacts(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    target_id: []const u8,
    want_tags: bool,
    shared: ?*repository.Repository,
) ?TargetFacts {
    var own: repository.Repository = undefined;
    var opened_here = false;
    const repo: *repository.Repository = shared orelse blk: {
        own.open(gpa, io, root_dir, .{ .mode = .full }) catch return null;
        opened_here = true;
        break :blk &own;
    };
    defer if (opened_here) own.deinit(gpa);
    const rid = repo.lookup(target_id) orelse return null;
    const owned_branch = gpa.dupe(u8, repo.branchOf(rid)) catch return null;
    errdefer gpa.free(owned_branch);
    const tags: []u8 = if (want_tags)
        tagsListing(gpa, repo.state(), rid, owned_branch) catch {
            gpa.free(owned_branch);
            return null;
        }
    else
        &.{};
    return .{ .branch = owned_branch, .tags = tags };
}

fn deriveTargetFacts(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    target_id: []const u8,
    want_tags: bool,
) ApplyError!TargetFacts {
    var assembly = try assembleState(gpa, log, target_id);
    defer assembly.deinit();

    var config = log.readConfig(gpa) catch |err| return mapContentError(err);
    defer if (config) |*owned| owned.deinit(gpa);
    // Storage deliberately leaves an absent config to its caller. The
    // repository-state convention is the same `trunk` fallback used by the
    // existing state tests; a present config always overrides it.
    // An empty stored value is treated as unset, matching `RepoView.loadInner`;
    // `branchOf` requires a non-empty name and an empty one would trip it.
    const main_branch = blk: {
        if (config) |owned| {
            if (owned.config.default_branch.len != 0) break :blk owned.config.default_branch;
        }
        break :blk "trunk";
    };
    // This assembly builds its own bare `RepoState`, so nothing has told it
    // §6.1's configured name -- `RepoView` is what does that on the ordinary
    // load path. Tell it here, at the point the fact becomes known, so the
    // state's own `branchValueOf` (the §7.2 leaf test) and `branchOf` below
    // resolve an untagged check-in to the SAME branch. Leaving them to
    // disagree is what made an imported repository's untagged check-ins
    // display as one branch and leaf-test as another.
    assembly.state.main_branch = main_branch;
    const branch = assembly.state.branchOf(assembly.target_rid, main_branch);
    const owned_branch = gpa.dupe(u8, branch) catch return error.OutOfMemory;
    errdefer gpa.free(owned_branch);

    const tags: []u8 = if (want_tags)
        try tagsListing(gpa, &assembly.state, assembly.target_rid, branch)
    else
        &.{};
    return .{ .branch = owned_branch, .tags = tags };
}

/// Fossil's `manifest.tags` layout: the branch line, then one `tag` line per
/// SYMBOLIC tag active on this check-in. Reserved non-symbolic names
/// ("branch", "closed", ...) are not names a user checks out and are excluded,
/// as fossil excludes them. Fossil emits its tag lines in tag-creation order,
/// which no other repository can reproduce; `tagsOf` returns the active rows
/// name-ascending, so zit's file is the same lines in a deterministic order.
fn tagsListing(gpa: std.mem.Allocator, state: *const repostate.RepoState, rid: repostate.Rid, branch: []const u8) ApplyError![]u8 {
    const sym_prefix = "sym-";
    const entries = state.tagsOf(gpa, rid) catch return error.OutOfMemory;
    defer gpa.free(entries);
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(gpa);
    out.print(gpa, "branch {s}\n", .{branch}) catch return error.OutOfMemory;
    for (entries) |e| {
        if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
        out.print(gpa, "tag {s}\n", .{e.name[sym_prefix.len..]}) catch return error.OutOfMemory;
    }
    return out.toOwnedSlice(gpa) catch return error.OutOfMemory;
}

/// State-record classification is intentionally conservative: any successful
/// classified manifest is crosslinked, any successful classified control
/// artifact ingests only tag effects, and all other successful classifications
/// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque:
/// the log frame format has no structural-kind bit to distinguish a blob from
/// malformed cards. Integrity failures remain fatal.
///
/// A classification failure is NOT fatal, and is not evidence of damage. A
/// conforming repository contains artifacts a conforming parser declines: the
/// artifact grammar requires the U card on a WIKI artifact, fossil elides it
/// when the submitter is "nobody", and fossil stores and syncs the result, so a
/// clone of an intact fossil repository carries such artifacts. Repository
/// state §4 separates the two conditions by the name: bytes that hash to the
/// name they are stored under are stored correctly and merely unclassifiable,
/// so they are opaque to state and skipped; bytes that do not are an integrity
/// failure and stay fatal. Verification is what distinguishes them, so it runs
/// before the skip rather than after — see the arm below.
///
/// This is why assembly must not answer a decline with a corruption claim: the
/// verified name has already ruled damage out.
pub fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly {
    const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory;
    mem.* = repostate.MemStore.init(gpa);
    errdefer {
        mem.deinit();
        gpa.destroy(mem);
    }
    var state = repostate.RepoState.init(gpa, mem.store());
    errdefer state.deinit();
    var target_rid: ?repostate.Rid = null;

    const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory;
    defer gpa.free(persisted_rids);
    // Same memo, same reason as `RepoView.loadInner`: bases persist strictly
    // before their deltas, so persisted-order resolution keeps each base hot
    // and collapses delta-chain read-amplification to ~1x. This walk ran
    // WITHOUT one while duplicating loadInner's sweep, which made every
    // update and commit re-reconstruct each delta chain from its root.
    var plaintext = logstore.PlaintextCache.init();
    defer plaintext.deinit(gpa);
    var raw_name: [64]u8 = undefined;
    for (persisted_rids) |rid| {
        const name = encodeName(log.hashOf(rid), &raw_name);
        const bytes = log.contentCached(gpa, rid, &plaintext) catch |err| return mapContentError(err);
        if (bytes == null) continue;
        defer gpa.free(bytes.?);

        // Read path: repostate recovery over persisted (fossil-origin) history
        // tolerates the legacy F-card trailing separator.
        var parsed = artifact.parseForImport(gpa, bytes.?) catch |err| switch (err) {
            error.SyntaxError => continue,
            error.OutOfMemory => return error.OutOfMemory,
            // Classification declined the artifact. Repository state §4 splits
            // this from damage by the name: bytes that hash to the name they
            // are stored under arrived and were stored correctly, so the only
            // event is that this reader's grammar does not admit them. Such an
            // artifact is opaque to state — it is skipped exactly as a
            // non-participating type is. Bytes that do NOT hash to their name
            // are the other condition, and stay fatal.
            //
            // A conforming repository reaches this: the artifact grammar makes
            // the U card required on a WIKI artifact, fossil elides it when the
            // submitter is "nobody", and fossil stores and syncs the result.
            else => {
                if (artifact.verify(name, bytes.?)) continue;
                return error.Corrupt;
            },
        };
        defer parsed.deinit(gpa);

        switch (parsed.manifest.kind) {
            .manifest => {
                if (!artifact.verify(name, bytes.?)) return error.Corrupt;
                const record = try manifestRecord(gpa, name, &parsed.manifest);
                defer {
                    gpa.free(record.parents);
                    gpa.free(record.tags);
                }
                const state_rid = state.crosslink(record) catch return error.OutOfMemory;
                if (std.mem.eql(u8, name, target_id)) target_rid = state_rid;
            },
            .control => {
                if (!artifact.verify(name, bytes.?)) return error.Corrupt;
                const record = try controlRecord(gpa, &parsed.manifest);
                defer gpa.free(record.tags);
                _ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory;
            },
            else => {},
        }
    }

    return .{
        .gpa = gpa,
        .mem = mem,
        .state = state,
        // Unreachable on the shipped path, and deliberately not given its own
        // variant for that reason. `applyCheckin` runs `buildPlan` on this same
        // `target_id` first, and `buildPlan` already proved the name valid, the
        // rid present, the content non-null, `artifact.verify` passing,
        // `parseForImport` succeeding and `kind == .manifest`. The loop above
        // re-parses those same bytes with the same two tests, and
        // `ridsInPersistedOrder` filters only phantoms — which a non-null
        // `content` already excluded — so the target is always crosslinked by
        // the time this runs. It stays a state-record failure rather than a
        // user error: were it ever to fire it would mean the assembler and the
        // log disagree, which is not something the caller named wrongly.
        .target_rid = target_rid orelse return error.StateRecordInvalid,
    };
}

const StateRecordParts = struct {
    mtime: repostate.Mtime,
    parents: []const []const u8 = &.{},
    tags: []const repostate.TagOp = &.{},
};

fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record {
    const parts = try stateRecordParts(gpa, manifest, true);
    return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags };
}

fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts {
    return stateRecordParts(gpa, manifest, false);
}

fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts {
    const date = manifest.d orelse return error.StateRecordInvalid;
    const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid;

    var parents = std.ArrayList([]const u8).empty;
    errdefer parents.deinit(gpa);
    if (include_parents) {
        for (manifest.p) |card| {
            var it = card.hashes();
            while (it.next()) |hash| {
                parents.append(gpa, hash) catch return error.OutOfMemory;
            }
        }
    }

    const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory;
    errdefer gpa.free(tags);
    for (manifest.t, tags) |card, *out| {
        out.* = .{
            .type = switch (card.sign) {
                .add => .singleton,
                .cancel => .cancel,
                .propagate => .propagate,
            },
            .name = card.name,
            .target = card.target,
            .value = card.value,
        };
    }

    return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags };
}

/// Which file staging could not materialize. Plain data, no allocation: the
/// path borrows the plan, which outlives the call, and the name is copied.
///
/// This exists because staging walks every file in the check-in — thousands on
/// a real repository, minutes of work — and a bare `Corrupt` at the end of that
/// says only that something, somewhere, is wrong. The path and hash are what
/// turn it into a question someone can answer: is that artifact in the source
/// repository, and did it fail to arrive?
pub const StageFault = struct {
    /// COPIED, not borrowed. `PlanEntry.path` is `gpa.dupe`d and freed by
    /// `Plan.deinit`, which `applyCheckin` runs from its own `defer` on the way
    /// out — so it is already gone by the time a caller renders this. An earlier
    /// version of this struct held the slice and documented it as borrowing a
    /// plan that "outlives the call"; it does not, and the diagnostic printed
    /// freed heap. A fault report that lies about the file is worse than the
    /// generic message it replaced.
    path_buf: [path_max]u8 = @as([path_max]u8, @splat(0)),
    path_len: u16 = 0,
    /// Also copied, for the same reason: `PlanEntry.name` is owned by the plan.
    name: [64]u8 = @as([64]u8, @splat(0)),
    name_len: u8 = 0,
    reason: enum { not_in_repository, content_absent, content_mismatch },

    /// Long enough for any path this repository can hold (`managed.max_path_len`
    /// is PATH_MAX), so a diagnostic never truncates a real filename.
    pub const path_max: usize = 4096;

    pub fn artifactName(self: *const StageFault) []const u8 {
        return self.name[0..self.name_len];
    }
    pub fn filePath(self: *const StageFault) []const u8 {
        return self.path_buf[0..self.path_len];
    }
};

/// Human-readable cause for the last staging fault, or a neutral line if none
/// was recorded. Shared so every verb that can hit this says the same thing
/// about the same failure.
pub fn stageFaultReason(buf: []u8) []const u8 {
    const f = last_stage_fault orelse
        return "a file's content is missing from this repository";
    const why: []const u8 = switch (f.reason) {
        .not_in_repository => "is not in this repository",
        .content_absent => "has no stored content in this repository",
        .content_mismatch => "does not match its recorded name",
    };
    return std.fmt.bufPrint(buf, "'{s}': content {s} {s}", .{ f.filePath(), f.artifactName(), why }) catch
        "a file's content is missing from this repository";
}

/// Why a referenced file can be absent from a repository that verifies clean.
///
/// Shared by every verb that can hit `FileContentMissing`, because the cause is
/// the same in all of them and it is not guessable from the missing path. The
/// format types artifacts by STRUCTURE (artifact-format §5.1) and repo-state
/// §4.4 exempts only what a parser declines to classify — so a stored blob whose
/// bytes are a valid check-in manifest IS a check-in, joins the DAG, and can
/// carry its own branch tags. A repository that tracks such a file therefore
/// holds check-ins of ANOTHER repository, whose file content it never had.
///
/// That is conformant derivation, not damage, which is why the refusal explains
/// rather than alleging corruption.
pub const foreign_checkin_note: []const u8 =
    "this happens when a repository stores artifacts that parse as check-ins of\n" ++
    "ANOTHER repository -- imported mirror metadata is the common source -- so they\n" ++
    "join the branch as check-ins whose blobs were never here. Nothing was modified.\n";

/// Set by `stagePlan` when it refuses a file; read only after `applyCheckin`
/// returns `error.FileContentMissing`.
pub var last_stage_fault: ?StageFault = null;

fn stageFaulted(entry_path: []const u8, name: []const u8, reason: anytype) error{FileContentMissing} {
    var f: StageFault = .{ .reason = reason };
    const n = @min(name.len, f.name.len);
    @memcpy(f.name[0..n], name[0..n]);
    f.name_len = @intCast(n);
    const pn = @min(entry_path.len, StageFault.path_max);
    @memcpy(f.path_buf[0..pn], entry_path[0..pn]);
    f.path_len = @intCast(pn);
    last_stage_fault = f;
    return error.FileContentMissing;
}

/// Mark, in BOTH plans, every path whose (digest, mode) pair is identical on
/// the two sides. Everything else -- content moved, mode flipped, path only on
/// one side -- stays unmarked and takes the full mutation path.
///
/// This is what makes `update`'s cost track the DIFF rather than the width:
/// before it, every update staged, journaled, deleted and rewrote the entire
/// tree, and the measured cost was ~52ms per tracked file regardless of how
/// little changed. It is sound only downstream of `requireClean`, which has
/// already proven every baseline file byte-correct on disk -- an unchanged
/// entry is not "skipped work", it is work whose result is already present.
fn markUnchanged(gpa: std.mem.Allocator, target: *Plan, old: *Plan) error{OutOfMemory}!void {
    var by_path: std.StringHashMapUnmanaged(*PlanEntry) = .empty;
    defer by_path.deinit(gpa);
    try by_path.ensureTotalCapacity(gpa, @intCast(old.entries.items.len));
    for (old.entries.items) |*entry| by_path.putAssumeCapacity(entry.path, entry);
    for (target.entries.items) |*entry| {
        const prior = by_path.get(entry.path) orelse continue;
        if (prior.mode != entry.mode) continue;
        if (!std.mem.eql(u8, prior.digestSlice(), entry.digestSlice())) continue;
        entry.unchanged = true;
        prior.unchanged = true;
    }
}

fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void {
    var parents: ParentCache = .{};
    defer parents.deinit(io);
    for (plan.entries.items) |entry| {
        if (entry.unchanged) continue;
        // A carried path's bytes are the working tree's, not the store's:
        // there is no artifact to look up and nothing to verify them against.
        // They are staged like any other payload so the commit/rollback rename
        // machinery below treats carried and check-in content identically.
        if (entry.carry) |bytes| {
            try writePayload(io, stage, entry.path, bytes, entry.mode, true, null, false, &parents);
            continue;
        }
        const rid = log.lookup(entry.digestSlice()) orelse
            return stageFaulted(entry.path, entry.name, .not_in_repository);
        const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
        if (bytes == null) return stageFaulted(entry.path, entry.name, .content_absent);
        defer gpa.free(bytes.?);
        if (!artifact.verify(entry.name, bytes.?))
            return stageFaulted(entry.path, entry.name, .content_mismatch);
        try writePayload(io, stage, entry.path, bytes.?, entry.mode, true, null, false, &parents);
    }
}

fn preflight(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    target: *const Plan,
    current_managed: *const managed.ManagedSet,
) ApplyError!void {
    // Ancestors already proven to be directories (or absent). A path-sorted
    // plan names the same few ancestors thousands of times; without this each
    // entry re-walked and re-stat'd every level above it from the root.
    var known_dirs: std.StringHashMapUnmanaged(void) = .empty;
    defer known_dirs.deinit(gpa);
    var parents: ParentCache = .{};
    defer parents.deinit(io);
    for (target.entries.items) |entry| {
        if (entry.unchanged) continue;
        var replaces_managed_ancestor = false;
        var component_end: usize = 0;
        while (std.mem.indexOfScalarPos(u8, entry.path, component_end, '/')) |slash| {
            const parent = entry.path[0..slash];
            if (known_dirs.contains(parent)) {
                component_end = slash + 1;
                continue;
            }
            const existing = try statExistingCached(&parents, io, worktree, parent);
            switch (existing) {
                .missing => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory,
                .stat => |stat| switch (stat.kind) {
                    .directory => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory,
                    .file, .sym_link => if (current_managed.contains(parent)) {
                        replaces_managed_ancestor = true;
                        break;
                    } else return error.UnmanagedCollision,
                    else => return error.UnmanagedCollision,
                },
            }
            component_end = slash + 1;
        }
        if (replaces_managed_ancestor) continue;
        const existing = try statExistingCached(&parents, io, worktree, entry.path);
        switch (existing) {
            .missing => {},
            .stat => |stat| switch (stat.kind) {
                .file, .sym_link => if (!current_managed.contains(entry.path)) return error.UnmanagedCollision,
                .directory => if (!try directoryContainsOnlyManaged(gpa, io, worktree, entry.path, current_managed)) return error.UnmanagedCollision,
                else => return error.UnmanagedCollision,
            },
        }
    }
}

fn commitMaterialization(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    next: std.Io.Dir,
    previous: std.Io.Dir,
    target: *const Plan,
    old: *const Plan,
    desired_managed: *const managed.ManagedSet,
    old_managed: *const managed.ManagedSet,
    created_directories: *CreatedDirectories,
    next_checkout: checkout.CheckoutState,
    manifest: ManifestSource,
    cone_update: ?checkout.SectionUpdate,
) ApplyError!void {
    _ = previous;
    _ = old_managed;
    // Preconditions of the commit seam — the managed-set / record / staged-tree
    // agreement this transition rests on, self-guarded here rather than trusted
    // from the caller:
    //   (iv) the record about to be written names EXACTLY the check-in whose
    //        payload `next` was staged from, so a completed commit never leaves
    //        a record pointing at a materialization that did not run; and
    //   the managed set to be written is that check-in's membership (one entry
    //        per target plan path).
    std.debug.assert(std.mem.eql(u8, next_checkout.version, manifest.target_id));
    std.debug.assert(desired_managed.iterate().len == target.entries.items.len);
    try removePlanFiles(io, worktree, old, true);
    try materializePlan(gpa, io, next, worktree, target, created_directories, .move);
    if (shouldInjectTestFault(.after_materialize)) return error.Storage;

    // Before the metadata writes, so a failure here still takes the ordinary
    // rollback rather than stranding a committed record beside a half-written
    // convenience file.
    try writeManifestFiles(gpa, io, worktree, next_checkout, desired_managed, manifest);
    // The membership and the record commit in ONE container write: there is no
    // window in which a crash leaves a new managed set beside an old checkout.
    const mbuf = gpa.alloc(u8, desired_managed.bytes) catch return error.OutOfMemory;
    defer gpa.free(mbuf);
    var ck_buf: [checkout.max_ck_payload]u8 = undefined;
    if (cone_update) |u| {
        std.debug.assert(u.tag == .cone); // the seam widens for exactly one tag
        checkout.updateSections(io, worktree, &.{
            .{ .tag = .managed, .payload = desired_managed.encode(mbuf) },
            .{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, next_checkout) },
            u,
        }) catch |err| return mapCheckoutError(err);
    } else {
        checkout.updateSections(io, worktree, &.{
            .{ .tag = .managed, .payload = desired_managed.encode(mbuf) },
            .{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, next_checkout) },
        }) catch |err| return mapCheckoutError(err);
    }
}

/// Where `writeManifestFiles` reads its payloads from: the log holding the
/// check-in artifact, the check-in's own name, and the tag listing already
/// derived for this target (empty unless `t` was selected).
const ManifestSource = struct {
    log: *logstore.LogStore,
    target_id: []const u8,
    tags: []const u8,
};

/// Write the working-tree manifest files `next_checkout.manifest_flags` selects.
/// No RFC section behind this: it is fossil's `manifest` setting re-expressed as
/// an explicit per-checkout opt-in, and the file contents match fossil's byte
/// for byte so a build that reads them cannot tell the two apart.
///
/// `r` is the check-in artifact followed by fossil's sterilizing line, `u` is
/// the check-in name plus a newline, `t` is the branch line and one line per
/// symbolic tag. A selector whose file name is TRACKED by the target check-in
/// is skipped: fossil has that guard and the reason is the same — a versioned
/// file the user committed outranks a convenience file, and silently replacing
/// it would lose data the repository is responsible for. No selector means no
/// write at all, which is the default.
fn writeManifestFiles(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    next_checkout: checkout.CheckoutState,
    tracked: *const managed.ManagedSet,
    src: ManifestSource,
) ApplyError!void {
    const flags = checkout.manifestSelectors(next_checkout.manifest_flags);
    if (flags.len == 0) return;
    std.debug.assert(flags.len <= checkout.manifest_selectors.len);

    for (flags) |selector| {
        const index = std.mem.indexOfScalar(u8, checkout.manifest_selectors, selector) orelse
            return error.CurrentStateInvalid; // a record carrying a selector no writer knows
        const path = manifest_paths[index];
        if (tracked.contains(path)) continue;

        switch (selector) {
            'r' => {
                var digest: [logstore.max_hash_len]u8 = undefined;
                const raw = try decodeValidatedName(src.target_id, &digest);
                const rid = src.log.lookup(raw) orelse return error.CheckinNotFound;
                const bytes = src.log.content(gpa, rid) catch |err| return mapContentError(err);
                if (bytes == null) return error.CheckinContentMissing;
                defer gpa.free(bytes.?);
                // The file is the check-in artifact verbatim, so it must still
                // hash to its own name before anything is written.
                if (!artifact.verify(src.target_id, bytes.?)) return error.CheckinNameMismatch;
                // ...and then the sterilizing line, byte-for-byte fossil's.
                // Without it the file on disk IS a valid artifact, so adding it
                // to a check-in would commit a second copy of that check-in
                // under a name that is not its own. Fossil defends against that
                // and so must zit; a build reading the D or C card is unaffected.
                var out: std.ArrayList(u8) = .empty;
                defer out.deinit(gpa);
                out.appendSlice(gpa, bytes.?) catch return error.OutOfMemory;
                out.appendSlice(gpa, sterilizer) catch return error.OutOfMemory;
                try writePayload(io, worktree, path, out.items, .regular, false, null, true, null);
            },
            'u' => {
                // 64 hex digits is the longest canonical name (§3.2); the
                // `buildPlan` gate has already proven `target_id` is one.
                var line: [2 * logstore.max_hash_len + 1]u8 = undefined;
                std.debug.assert(artifact.validateName(src.target_id));
                @memcpy(line[0..src.target_id.len], src.target_id);
                line[src.target_id.len] = '\n';
                try writePayload(io, worktree, path, line[0 .. src.target_id.len + 1], .regular, false, null, true, null);
            },
            't' => try writePayload(io, worktree, path, src.tags, .regular, false, null, true, null),
            else => unreachable, // the selector table above is exhaustive
        }
    }
}

fn rollbackMaterialization(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    previous: std.Io.Dir,
    target: *const Plan,
    old: *const Plan,
    old_managed: *const managed.ManagedSet,
    created_directories: *const CreatedDirectories,
) ApplyError!void {
    try removePlanFiles(io, worktree, target, false);
    try created_directories.remove(io, worktree);
    try materializePlan(gpa, io, previous, worktree, old, null, .copy);
    old_managed.write(io, worktree) catch |err| return mapManagedError(err);
}

fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void {
    for (plan.entries.items) |entry| {
        if (entry.unchanged) continue;
        const removed = blk: {
            const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
                error.PathMissing => break :blk false,
                else => return @errorCast(err),
            };
            defer parent.close(io);
            parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
                error.FileNotFound => break :blk false,
                else => return error.Storage,
            };
            break :blk true;
        };
        if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path);
    }
}

/// Deletes only empty ancestors of a removed managed file. A non-empty
/// directory is never recursed into, so unmanaged entries remain untouched.
fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void {
    var index = path.len;
    while (index > 0) {
        index -= 1;
        if (path[index] != '/') continue;
        const ancestor = path[0..index];
        const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) {
            error.PathMissing => continue,
            else => return @errorCast(err),
        };
        defer parent.close(io);
        parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
            error.FileNotFound, error.DirNotEmpty => {},
            else => return error.Storage,
        };
    }
}

/// How a staged payload reaches the working tree. `.move` renames it into
/// place -- the stage lives inside the worktree, so it is one same-filesystem
/// rename per entry, and the bytes are neither read back nor written twice.
/// `.copy` reads and rewrites, leaving the staged tree intact: the rollback
/// path uses it so a failed inverse still has `previous/` as its complete
/// recovery copy (see the retain branch in `applyCheckin`).
const Transfer = enum { move, copy };

fn materializePlan(
    gpa: std.mem.Allocator,
    io: std.Io,
    stage: std.Io.Dir,
    worktree: std.Io.Dir,
    plan: *const Plan,
    created_directories: ?*CreatedDirectories,
    transfer: Transfer,
) ApplyError!void {
    var sources: ParentCache = .{};
    defer sources.deinit(io);
    var destinations: ParentCache = .{};
    defer destinations.deinit(io);
    for (plan.entries.items) |entry| {
        if (entry.unchanged) continue;
        try prepareDestination(io, worktree, entry.path, &destinations);
        const source_parent = openParentCached(&sources, io, stage, entry.path, false, null) catch |err| switch (err) {
            error.PathMissing => return error.Storage,
            else => return @errorCast(err),
        };

        switch (transfer) {
            .move => {
                const dest = openParentCached(&destinations, io, worktree, entry.path, true, created_directories) catch |err| switch (err) {
                    error.PathMissing => return error.Storage,
                    else => return @errorCast(err),
                };
                // A plain rename, not `renamePreserve`: on Darwin the latter
                // is link+unlink, and that linkat was half the verb's wall
                // time. `preflight` already proved the slot is free or
                // managed-and-removed, so there is nothing to preserve.
                source_parent.dir.rename(source_parent.name, dest.dir, dest.name, io) catch return error.Storage;
            },
            .copy => switch (entry.mode) {
                .symlink => {
                    const stat = source_parent.dir.statFile(io, source_parent.name, .{ .follow_symlinks = false }) catch return error.Storage;
                    if (stat.kind != .sym_link) return error.Storage;
                    const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
                    defer gpa.free(bytes);
                    const n = source_parent.dir.readLink(io, source_parent.name, bytes) catch return error.Storage;
                    if (n != bytes.len) return error.Storage;
                    try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories, false, &destinations);
                },
                .regular, .executable => {
                    var source = source_parent.dir.openFile(io, source_parent.name, .{
                        .allow_directory = false,
                        .follow_symlinks = false,
                        .resolve_beneath = true,
                    }) catch return error.Storage;
                    defer source.close(io);
                    const stat = source.stat(io) catch return error.Storage;
                    const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
                    defer gpa.free(bytes);
                    const n = source.readPositionalAll(io, bytes, 0) catch return error.Storage;
                    if (n != bytes.len) return error.Storage;
                    try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories, false, &destinations);
                },
            },
        }
    }
}

fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8, cache: ?*ParentCache) ApplyError!void {
    const existing = try statExistingCached(cache, io, worktree, path);
    switch (existing) {
        .missing => {},
        .stat => |stat| switch (stat.kind) {
            .directory => {
                const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
                    error.PathMissing => return error.Storage,
                    else => return @errorCast(err),
                };
                defer parent.close(io);
                parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
                    error.DirNotEmpty => return error.UnmanagedCollision,
                    else => return error.Storage,
                };
            },
            else => return error.UnmanagedCollision,
        },
    }
}

/// `sync` fsyncs a regular file before returning. The bulk plan walks pass
/// false: fossil fsyncs nothing on checkout, and an fsync per staged file was
/// ~8% of a tree materialization. The single-file writers (`revert`,
/// the manifest stamps) keep it. `parents`, when given, amortizes the
/// directory walk across a path-sorted plan.
fn writePayload(
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    bytes: []const u8,
    mode: artifact.FileMode,
    exclusive: bool,
    created_directories: ?*CreatedDirectories,
    sync: bool,
    parents: ?*ParentCache,
) ApplyError!void {
    const parent = (if (parents) |cache|
        openParentCached(cache, io, root, path, true, created_directories)
    else
        openParentNoFollow(io, root, path, true, created_directories)) catch |err| switch (err) {
        error.PathMissing => return error.Storage,
        else => return @errorCast(err),
    };
    defer parent.close(io);

    switch (mode) {
        .symlink => {
            if (!exclusive) {
                parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
                    error.FileNotFound => {},
                    else => return error.Storage,
                };
            }
            parent.dir.symLink(io, bytes, parent.name, .{}) catch return error.Storage;
        },
        .regular, .executable => {
            var file = parent.dir.createFile(io, parent.name, .{
                .read = false,
                .exclusive = exclusive,
                .truncate = true,
                .permissions = switch (mode) {
                    .regular => .default_file,
                    .executable => .executable_file,
                    .symlink => unreachable,
                },
                .resolve_beneath = true,
            }) catch return error.Storage;
            defer file.close(io);
            // `permissions` applies only when the file is created; an
            // overwrite keeps the old mode. Set the execute bits so a mode
            // change reaches an existing file too, as Fossil does: execute
            // wherever the file is readable, or none.
            if (!exclusive) try setExecutable(io, file, mode == .executable);
            file.writePositionalAll(io, bytes, 0) catch return error.Storage;
            if (sync) file.sync(io) catch return error.Storage;
        },
    }
}

/// The closed error set `restoreFile` surfaces: a storage failure while
/// materializing the requested working-tree entry. `SymlinkUnsupported` stays
/// in the public set for compatibility but is no longer returned for F-cards.
pub const RestoreError = error{ Storage, SymlinkUnsupported };

/// Overwrite the single worktree file `path` under `root` with `bytes` at
/// `mode` (artifact §8.4 effective-file restore), creating any missing parent
/// directories. Unlike the transactional `applyCheckin`, this is a targeted,
/// non-exclusive write of ONE already-resolved file: `zit revert` reconstructs
/// the check-in's blob and hands it here, leaving the managed set untouched.
/// Symlink targets are created from `bytes` exactly; all materialization
/// failures surface as `Storage`. Regular-file writes are fsync'd before
/// returning.
pub fn restoreFile(
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    bytes: []const u8,
    mode: artifact.FileMode,
) RestoreError!void {
    writePayload(io, root, path, bytes, mode, false, null, true, null) catch |e| switch (e) {
        error.SymlinkUnsupported => return error.SymlinkUnsupported,
        else => return error.Storage,
    };
}

/// Delete the single worktree file `path` under `root`, pruning any parent
/// directories the delete leaves empty — the symmetric inverse of `restoreFile`
/// (`zit undo` uses it to remove a file the undone mutation created). Reuses the
/// same no-follow parent walk and empty-ancestor prune the transactional path
/// uses; an already-absent path is success, not an error. Fsync of the
/// containing directory is the OS's to schedule; the delete is durable-ordered
/// after the journal swap that authorized it.
pub fn removeFile(io: std.Io, root: std.Io.Dir, path: []const u8) RestoreError!void {
    const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
        error.PathMissing => return, // parent gone => nothing to delete
        else => return error.Storage,
    };
    defer parent.close(io);
    parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
        error.FileNotFound => return, // already absent
        else => return error.Storage,
    };
    pruneEmptyPlanAncestors(io, root, path) catch return error.Storage;
}

// ---------------------------------------------------------------------------
// Merge materialization (checkout-layer seam for `zit merge`).
//
// The CLI computes a three-way merge purely, then hands the outcome set here as
// `MergeEntry` values. This layer owns the transactional filesystem effects,
// reusing the same journal + write-temp+fsync + rollback primitives
// `applyCheckin` uses. It never inspects bytes for conflict markers — surfacing
// conflicts is the CLI's job.
// ---------------------------------------------------------------------------

/// Verify the working tree is clean against its current checkout — the same
/// `requireClean` gate `applyCheckin` runs (every managed file matches its
/// checked-in content and the membership has not drifted), discarding the Plan.
/// `zit merge`'s refusal gate calls this to reject a dirty tree (B-merge-14)
/// before any mutation; a clean tree returns void, a dirty one `WorktreeNotClean`.
/// No mutation.
pub fn requireCleanTree(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
) ApplyError!void {
    var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer current_managed.deinit();
    var plan = try requireClean(gpa, io, worktree, log, &current_managed);
    plan.deinit(gpa);
}

/// One per-path effect of a computed merge. `write` carries the merged bytes and
/// the CURRENT-side attribute (`perm`: null/"" = regular, "x" = executable,
/// "l" = symlink — B-merge-23, the working file keeps its side's mode); `delete`
/// removes the path.
pub const MergeAction = union(enum) {
    write: struct { bytes: []const u8, perm: ?[]const u8 },
    delete,
};

/// A single path plus the action to apply to it.
/// Sparse-cone RFC section 4.4 -- one resolved out-of-cone outcome of a
/// cross-cone merge, held in the checkout container's merge_overlay section
/// until the next commit composes it. `hash == null` is theirs' deletion.
pub const OverlayEntry = struct {
    path: []const u8,
    hash: ?[]const u8,
    perm: ?[]const u8,
};

/// merge_overlay wire form: magic "ZITOVLY1" | count u16 LE | per entry:
/// flag u8 (1 = write, 0 = delete) | path u16 LE + bytes | hash u8 + bytes
/// (write only) | perm u8 + bytes (write only, 0 = none). Fail-closed parse:
/// authoritative state, never best-effort.
const overlay_magic = "ZITOVLY1";

pub fn encodeOverlay(gpa: std.mem.Allocator, entries: []const OverlayEntry) error{ OutOfMemory, Overflow }![]u8 {
    // Explicit rejections first (release_assert rule 5): the asserts below
    // restate already-established facts, never guard in release builds.
    if (entries.len == 0) return error.Overflow; // an empty overlay is spelled "no section"
    if (entries.len > std.math.maxInt(u16)) return error.Overflow;
    std.debug.assert(entries.len >= 1);
    std.debug.assert(entries.len <= std.math.maxInt(u16));
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(gpa);
    try out.appendSlice(gpa, overlay_magic);
    var n16: [2]u8 = undefined;
    std.mem.writeInt(u16, &n16, @intCast(entries.len), .little);
    try out.appendSlice(gpa, &n16);
    for (entries) |entry| {
        if (entry.path.len == 0 or entry.path.len > std.math.maxInt(u16)) return error.Overflow;
        if (entry.hash) |h| {
            if (h.len == 0 or h.len > std.math.maxInt(u8)) return error.Overflow;
            if (entry.perm) |perm_check| {
                if (perm_check.len > std.math.maxInt(u8)) return error.Overflow;
            }
        }
        try out.append(gpa, if (entry.hash != null) 1 else 0);
        std.mem.writeInt(u16, &n16, @intCast(entry.path.len), .little);
        try out.appendSlice(gpa, &n16);
        try out.appendSlice(gpa, entry.path);
        if (entry.hash) |h| {
            try out.append(gpa, @intCast(h.len));
            try out.appendSlice(gpa, h);
            const perm = entry.perm orelse "";
            try out.append(gpa, @intCast(perm.len));
            try out.appendSlice(gpa, perm);
        }
    }
    return out.toOwnedSlice(gpa);
}

/// Entries borrow `bytes`; the arena (or caller) owns both lifetimes.
pub fn decodeOverlay(gpa: std.mem.Allocator, bytes: []const u8) error{ OutOfMemory, Corrupt }![]OverlayEntry {
    if (bytes.len < overlay_magic.len + 2) return error.Corrupt;
    if (!std.mem.eql(u8, bytes[0..overlay_magic.len], overlay_magic)) return error.Corrupt;
    const count = std.mem.readInt(u16, bytes[overlay_magic.len..][0..2], .little);
    if (count == 0) return error.Corrupt;
    const entries = try gpa.alloc(OverlayEntry, count);
    errdefer gpa.free(entries);
    var off: usize = overlay_magic.len + 2;
    for (entries) |*entry| {
        if (bytes.len - off < 3) return error.Corrupt;
        const flag = bytes[off];
        if (flag > 1) return error.Corrupt;
        const path_len = std.mem.readInt(u16, bytes[off + 1 ..][0..2], .little);
        off += 3;
        if (path_len == 0 or bytes.len - off < path_len) return error.Corrupt;
        entry.path = bytes[off..][0..path_len];
        off += path_len;
        if (flag == 1) {
            if (bytes.len - off < 1) return error.Corrupt;
            const hash_len = bytes[off];
            off += 1;
            if (hash_len == 0 or bytes.len - off < hash_len) return error.Corrupt;
            entry.hash = bytes[off..][0..hash_len];
            off += hash_len;
            if (bytes.len - off < 1) return error.Corrupt;
            const perm_len = bytes[off];
            off += 1;
            if (bytes.len - off < perm_len) return error.Corrupt;
            entry.perm = if (perm_len == 0) null else bytes[off..][0..perm_len];
            off += perm_len;
        } else {
            entry.hash = null;
            entry.perm = null;
        }
    }
    if (off != bytes.len) return error.Corrupt; // no residue
    return entries;
}

pub const MergeEntry = struct {
    path: []const u8,
    action: MergeAction,
};

/// Journal-armed, transactional materialization of a computed merge outcome set,
/// then the new `.zit-managed` and `.zit-checkout`. Order (B-merge-3): (1)
/// journal the pre-images of every affected path ∪ the current checkout ∪ the
/// current managed set, fsynced before the first byte moves; (2) write the new
/// checkout (the caller sets `merge_parent` to the other side's hash) and the
/// new managed set — the pending merge and its membership are recorded FIRST so
/// a crash mid-apply still names them for `zit undo`; (3) materialize each
/// entry. Any failure during (3) rolls files, the managed set AND the checkout
/// back to the pre-merge state and drops the journal, exactly as `applyCheckin`
/// rolls back.
///
/// The managed-set mutation is DERIVED here from the same `entries` stream that
/// drives the filesystem effects, never passed in alongside it: a `.write`
/// registers its path (insert is idempotent, so an ordinary edit-merge is a
/// no-op) and a `.delete` deregisters it. One stream, one truth — a merge that
/// adds a file cannot leave it untracked (blocker B5) and a merge that deletes
/// one cannot leave a phantom "missing" entry behind.
pub fn applyMerge(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    entries: []const MergeEntry,
    overlay_bytes: ?[]const u8,
    new_checkout: checkout.CheckoutState,
) ApplyError!void {
    for (entries) |entry| try validateWorktreePath(entry.path);

    // Sparse-cone RFC section 4.4: the caller resolves out-of-cone changes
    // at record level (the overlay) and hands this layer IN-CONE entries
    // only. The error remains for a caller that skips that split -- crossing
    // here would silently lose the change at the next commit.
    {
        var stored_cone = try readStoredCone(gpa, io, worktree);
        defer if (stored_cone) |*c| c.deinit(gpa);
        if (stored_cone) |*c| {
            for (entries) |entry| {
                if (!c.covers(entry.path)) return error.SparseMergeUnsupported;
            }
        }
    }

    // (1) Arm the undo journal before any working byte moves (B-merge-3): the
    //     pre-mutation bytes of every affected path plus the current
    //     `.zit-checkout`/`.zit-managed`, fsynced HERE. A device fault aborts
    //     before mutating — no recoverable pre-image, no mutation.
    try recordMergeUndoImage(gpa, io, worktree, entries);

    // In-process rollback images: the pre-merge bytes/mode/presence of each
    // affected path, plus the pre-merge checkout and managed set, so a mid-apply
    // failure restores the tree exactly. The journal remains the crash-recovery
    // image.
    var images = try captureMergeImages(gpa, io, worktree, entries);
    defer images.deinit(gpa);
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const old_checkout = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);

    // Two independent reads rather than one rebuilt copy: the on-disk record
    // carries more than the membership (a pending `mv` rename), and rebuilding
    // `new` from `old.iterate()` would silently drop it.
    var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer old_managed.deinit();
    var new_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer new_managed.deinit();
    // Both sets are a fresh read of the SAME on-disk `.zit-managed`, so before
    // the outcome stream is folded into `new_managed` they hold identical
    // membership — `old_managed` is the untouched pre-merge baseline the
    // rollback restores to.
    std.debug.assert(new_managed.iterate().len == old_managed.iterate().len);
    try applyMergeMembership(&new_managed, entries);

    // (2) Record the pending merge (checkout carries merge_parent = other) and
    //     the membership it implies in ONE container write — a failure here has
    //     moved no working byte and left no half-recorded merge, but the
    //     journal is already armed, so roll it back.
    {
        const mbuf = gpa.alloc(u8, new_managed.bytes) catch return error.OutOfMemory;
        defer gpa.free(mbuf);
        var merge_ck_buf: [checkout.max_ck_payload]u8 = undefined;
        checkout.updateSections(io, worktree, &.{
            .{ .tag = .managed, .payload = new_managed.encode(mbuf) },
            .{ .tag = .checkout, .payload = checkout.encodeCk(&merge_ck_buf, new_checkout) },
            // Sparse-cone RFC section 4.4: the resolved out-of-cone outcomes
            // ride the SAME splice as the pending-merge record -- the pair is
            // never observable half-updated, and a merge with no overlay
            // clears any stale one an undone earlier merge left behind.
            .{ .tag = .merge_overlay, .payload = overlay_bytes },
        }) catch |err| {
            journal.clear(io, worktree);
            return mapCheckoutError(err);
        };
    }

    // (3) Materialize the outcome set; roll files + managed + checkout back on
    //     any failure.
    materializeMergeEntries(io, worktree, entries) catch |err| {
        rollbackMerge(io, worktree, images.items, old_checkout, &old_managed) catch {
            // The tree is half-restored; the undo journal is the only complete
            // recovery image, so retain it and report the failed inverse.
            return error.RollbackFailed;
        };
        // Clean rollback: the tree is back to pre-merge, so drop the journal —
        // a cleanly-abandoned merge leaves no spurious undo level (B-merge-3).
        journal.clear(io, worktree);
        return err;
    };
}

/// Folds the merge outcome set into `set`'s membership: every `.write` path is
/// inserted (idempotent — an ordinary edit-merge changes nothing) and every
/// `.delete` path removed (absent is a no-op). No I/O.
fn applyMergeMembership(set: *managed.ManagedSet, entries: []const MergeEntry) ApplyError!void {
    for (entries) |entry| switch (entry.action) {
        .write => _ = set.insert(entry.path) catch |err| return mapManagedError(err),
        .delete => _ = set.remove(entry.path),
    };
}

/// Build the affected-path set (deduplicated) and hand it to the undo journal.
fn recordMergeUndoImage(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    entries: []const MergeEntry,
) ApplyError!void {
    var affected = managed.ManagedSet.init(gpa);
    defer affected.deinit();
    for (entries) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err);

    journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Storage, error.Corrupt, error.Empty => return error.Storage,
    };
}

const MergeImage = struct {
    /// Borrows the caller's `entry.path`, which outlives this call.
    path: []const u8,
    present: bool,
    mode: artifact.FileMode,
    /// Pre-merge bytes when `present`; `gpa`-owned. Empty otherwise.
    blob: []const u8,
};

const MergeImages = struct {
    items: []MergeImage,

    fn deinit(self: *MergeImages, gpa: std.mem.Allocator) void {
        for (self.items) |img| if (img.present) gpa.free(img.blob);
        gpa.free(self.items);
        self.* = undefined;
    }
};

fn captureMergeImages(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    entries: []const MergeEntry,
) ApplyError!MergeImages {
    const items = gpa.alloc(MergeImage, entries.len) catch return error.OutOfMemory;
    var filled: usize = 0;
    errdefer {
        for (items[0..filled]) |img| if (img.present) gpa.free(img.blob);
        gpa.free(items);
    }
    for (entries) |entry| {
        items[filled] = try captureMergeImage(gpa, io, worktree, entry.path);
        filled += 1;
    }
    // Exactly one rollback pre-image per affected path: the roll-back walk below
    // must be able to restore every path the merge is about to touch, so the
    // image count and the outcome-set length can never diverge.
    std.debug.assert(filled == entries.len);
    return .{ .items = items };
}

fn captureMergeImage(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!MergeImage {
    const absent = MergeImage{ .path = path, .present = false, .mode = .regular, .blob = "" };
    const existing = try statExisting(io, worktree, path);
    const stat = switch (existing) {
        .missing => return absent,
        .stat => |value| value,
    };
    if (stat.kind != .file) return absent;

    const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
        error.PathMissing => return absent,
        else => return @errorCast(err),
    };
    defer parent.close(io);
    var file = parent.dir.openFile(io, parent.name, .{
        .allow_directory = false,
        .follow_symlinks = false,
        .resolve_beneath = true,
    }) catch return error.Storage;
    defer file.close(io);
    const blob = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
    errdefer gpa.free(blob);
    const n = file.readPositionalAll(io, blob, 0) catch return error.Storage;
    if (n != blob.len) return error.Storage;
    const mode: artifact.FileMode = if (statIsExecutable(stat)) .executable else .regular;
    return .{ .path = path, .present = true, .mode = mode, .blob = blob };
}

fn materializeMergeEntries(io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry) ApplyError!void {
    for (entries, 0..) |entry, i| {
        // Deterministic mid-apply fault (tests only): the first entry is applied,
        // then this fires, so rollback must restore a partially-mutated tree.
        if (i == 1 and shouldInjectTestFault(.merge_mid_apply)) return error.Storage;
        switch (entry.action) {
            .write => |w| {
                const mode = try modeFromPerm(w.perm);
                try writePayload(io, worktree, entry.path, w.bytes, mode, false, null, true, null);
            },
            .delete => removeFile(io, worktree, entry.path) catch |err| switch (err) {
                error.SymlinkUnsupported => return error.SymlinkUnsupported,
                error.Storage => return error.Storage,
            },
        }
    }
}

fn rollbackMerge(
    io: std.Io,
    worktree: std.Io.Dir,
    images: []const MergeImage,
    old_checkout: ?checkout.CheckoutState,
    old_managed: *const managed.ManagedSet,
) ApplyError!void {
    for (images) |img| {
        if (img.present) {
            restoreFile(io, worktree, img.path, img.blob, img.mode) catch return error.Storage;
        } else {
            removeFile(io, worktree, img.path) catch return error.Storage;
        }
    }
    // The membership mutation is part of the merge, so its inverse is part of
    // the rollback: a rolled-back merge must leave nothing registered that it
    // registered, and nothing deregistered that it removed. One container
    // write restores both records (an absent pre-merge checkout removes the
    // section).
    const mbuf = old_managed.gpa.alloc(u8, old_managed.bytes) catch return error.OutOfMemory;
    defer old_managed.gpa.free(mbuf);
    var ck_buf: [checkout.max_ck_payload]u8 = undefined;
    const ck_payload: ?[]const u8 = if (old_checkout) |ck| checkout.encodeCk(&ck_buf, ck) else null;
    checkout.updateSections(io, worktree, &.{
        .{ .tag = .managed, .payload = old_managed.encode(mbuf) },
        .{ .tag = .checkout, .payload = ck_payload },
    }) catch |err| return mapCheckoutError(err);
}

/// Maps a fossil-style perm token (the current side's attribute) to a file mode:
/// null/"" = regular, "x" = executable, "l" = symlink (rejected downstream as
/// `SymlinkUnsupported`). Any other token is a malformed outcome (`Corrupt`).
fn modeFromPerm(perm: ?[]const u8) ApplyError!artifact.FileMode {
    const token = perm orelse return .regular;
    if (token.len == 0) return .regular;
    if (std.mem.eql(u8, token, "x")) return .executable;
    if (std.mem.eql(u8, token, "l")) return .symlink;
    return error.Corrupt;
}

fn setExecutable(io: std.Io, file: std.Io.File, executable: bool) ApplyError!void {
    if (comptime !std.Io.File.Permissions.has_executable_bit) return;
    const st = file.stat(io) catch return error.Storage;
    const old = st.permissions.toMode();
    const new = if (executable) old | ((old & 0o444) >> 2) else old & ~@as(@TypeOf(old), 0o111);
    if (new == old) return;
    file.setPermissions(io, .fromMode(new)) catch return error.Storage;
}

fn statIsExecutable(stat: std.Io.File.Stat) bool {
    if (comptime std.Io.File.Permissions.has_executable_bit) {
        return stat.permissions.toMode() & 0o111 != 0;
    }
    return false;
}

fn statExisting(io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath {
    return statExistingCached(null, io, root, path);
}

/// `statExisting` through an optional `ParentCache` -- the plan walks pass one
/// so a path-sorted plan stats its entries from one open parent handle.
fn statExistingCached(cache: ?*ParentCache, io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath {
    const parent = (if (cache) |c|
        openParentCached(c, io, root, path, false, null)
    else
        openParentNoFollow(io, root, path, false, null)) catch |err| switch (err) {
        error.PathMissing => return .missing,
        else => return @errorCast(err),
    };
    defer parent.close(io);
    const stat = parent.dir.statFile(io, parent.name, .{ .follow_symlinks = false }) catch |err| switch (err) {
        error.FileNotFound => return .missing,
        else => return error.Storage,
    };
    return .{ .stat = stat };
}

fn openParentNoFollow(
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    create: bool,
    created_directories: ?*CreatedDirectories,
) ParentError!OpenedParent {
    var current = root;
    var owns_current = false;
    errdefer if (owns_current) current.close(io);

    var start: usize = 0;
    while (std.mem.indexOfScalarPos(u8, path, start, '/')) |slash| {
        const component = path[start..slash];
        const next = current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch |err| switch (err) {
            error.FileNotFound => if (create) blk: {
                var created = true;
                current.createDir(io, component, .default_dir) catch |create_err| switch (create_err) {
                    error.PathAlreadyExists => created = false,
                    else => return error.Storage,
                };
                if (created) if (created_directories) |directories| directories.record(path[0..slash]);
                break :blk current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
            } else return error.PathMissing,
            else => return error.Storage,
        };
        if (owns_current) current.close(io);
        current = next;
        owns_current = true;
        start = slash + 1;
    }
    return .{ .dir = current, .name = path[start..], .owned = owns_current };
}

fn directoryContainsOnlyManaged(
    gpa: std.mem.Allocator,
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    current_managed: *const managed.ManagedSet,
) ApplyError!bool {
    const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
        error.PathMissing => return error.Storage,
        else => return @errorCast(err),
    };
    defer parent.close(io);
    var dir = parent.dir.openDir(io, parent.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
    defer dir.close(io);
    return directoryEntriesAreManaged(gpa, io, dir, path, current_managed);
}

fn directoryEntriesAreManaged(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    prefix: []const u8,
    current_managed: *const managed.ManagedSet,
) ApplyError!bool {
    var iterator = dir.iterate();
    while (iterator.next(io) catch return error.Storage) |entry| {
        const full = joinPath(gpa, prefix, entry.name) catch return error.OutOfMemory;
        defer gpa.free(full);
        switch (entry.kind) {
            .file, .sym_link => if (!current_managed.contains(full)) return false,
            .directory => {
                var child = dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
                defer child.close(io);
                if (!try directoryEntriesAreManaged(gpa, io, child, full, current_managed)) return false;
            },
            else => return false,
        }
    }
    return true;
}

fn joinPath(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]u8 {
    const out = try gpa.alloc(u8, prefix.len + 1 + name.len);
    @memcpy(out[0..prefix.len], prefix);
    out[prefix.len] = '/';
    @memcpy(out[prefix.len + 1 ..], name);
    return out;
}

fn createTransaction(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError![]const u8 {
    var random: [12]u8 = undefined;
    var encoded: [64]u8 = undefined;
    var attempt: usize = 0;
    while (attempt < 32) : (attempt += 1) {
        io.randomSecure(&random) catch return error.Storage;
        const suffix = encodeName(&random, &encoded);
        // Recomputed for the prefix, not guessed: 13-byte prefix + the 24-hex
        // suffix of a 12-byte nonce.
        var name: [".zit-tmp-txn-".len + 24]u8 = undefined;
        const prefix = ".zit-tmp-txn-";
        @memcpy(name[0..prefix.len], prefix);
        @memcpy(name[prefix.len .. prefix.len + suffix.len], suffix);
        // Allocate the caller-owned name before the filesystem side effect:
        // an ordinary allocation failure must leave no transaction directory.
        const owned_name = gpa.dupe(u8, name[0 .. prefix.len + suffix.len]) catch return error.OutOfMemory;
        worktree.createDir(io, owned_name, .default_dir) catch |err| {
            gpa.free(owned_name);
            switch (err) {
                error.PathAlreadyExists => continue,
                else => return error.Storage,
            }
        };
        return owned_name;
    }
    return error.TransactionUnavailable;
}

pub fn validateWorktreePath(path: []const u8) ApplyError!void {
    return control_paths.validateWorktreePath(path);
}

fn isPathPrefix(prefix: []const u8, path: []const u8) bool {
    return path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/';
}

/// The largest working file a carrying update will merge. A path over the cap
/// is `.unsupported`, not silently truncated — the same posture as every other
/// content read that has a ceiling.
pub const max_carry_bytes: usize = 1 << 30;

/// What a working path holds, for the purpose of carrying it across an update.
pub const WorktreeContent = union(enum) {
    /// Nothing is at this path.
    absent,
    /// A regular (or executable) file, read whole.
    content: Carry.Content,
    /// Something a three-way merge has no bytes for: a symlink, a directory, a
    /// device node, or a file past `max_carry_bytes`. The caller names the path
    /// and refuses rather than guessing which side should win a type change.
    unsupported,
};

/// Read `path` from the working tree as merge input. The single place that
/// decides a worktree file's `FileMode`, so it cannot drift from
/// `modeMatches`, which decides whether that same mode is clean.
pub fn readWorktreeContent(
    arena: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    path: []const u8,
) ApplyError!WorktreeContent {
    const st = worktree.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) {
        error.FileNotFound, error.NotDir => return .absent,
        else => return error.Storage,
    };
    if (st.kind != .file) return .unsupported;
    if (st.size > max_carry_bytes) return .unsupported;
    const executable = if (comptime std.Io.File.Permissions.has_executable_bit)
        st.permissions.toMode() & 0o111 != 0
    else
        false;
    const bytes = worktree.readFileAlloc(io, path, arena, .limited(max_carry_bytes)) catch |err| switch (err) {
        error.FileNotFound, error.NotDir => return .absent,
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage,
    };
    return .{ .content = .{
        .bytes = bytes,
        .mode = if (executable) .executable else .regular,
    } };
}

fn modeMatches(stat: std.Io.File.Stat, mode: artifact.FileMode) bool {
    if (comptime std.Io.File.Permissions.has_executable_bit) {
        const executable = stat.permissions.toMode() & 0o111 != 0;
        return switch (mode) {
            .regular => !executable,
            .executable => executable,
            .symlink => false,
        };
    }
    return mode != .symlink;
}

fn decodeValidatedName(name: []const u8, out: *[logstore.max_hash_len]u8) ApplyError![]const u8 {
    if (!artifact.validateName(name)) return error.InvalidCheckinId;
    const len = name.len / 2;
    var i: usize = 0;
    while (i < len) : (i += 1) {
        out[i] = (hexNibble(name[i * 2]) << 4) | hexNibble(name[i * 2 + 1]);
    }
    return out[0..len];
}

fn hexNibble(c: u8) u8 {
    return if (c >= '0' and c <= '9') c - '0' else c - 'a' + 10;
}

fn encodeName(raw: []const u8, out: *[64]u8) []const u8 {
    const alphabet = "0123456789abcdef";
    for (raw, 0..) |byte, i| {
        out[i * 2] = alphabet[byte >> 4];
        out[i * 2 + 1] = alphabet[byte & 0x0f];
    }
    return out[0 .. raw.len * 2];
}

fn mapContentError(err: logstore.ContentError) ApplyError {
    return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
}

fn mapManagedError(err: managed.ManagedError) ApplyError {
    return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt, error.TooLong, error.TooMany => error.CurrentStateInvalid,
    };
}

fn mapCheckoutError(err: checkout.CheckoutError) ApplyError {
    return switch (err) {
        error.Storage => error.Storage,
        error.Corrupt => error.CurrentStateInvalid,
    };
}

fn mapTargetParseError(err: artifact.ParseError) ApplyError {
    return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        else => error.TargetNotManifest,
    };
}

fn mapBaselineParseError(err: artifact.ParseError) ApplyError {
    return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        else => error.BaselineNotManifest,
    };
}
const TestBaseline = struct {
    files: []const artifact.FCard,

    fn resolve(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
        const self: *const TestBaseline = @ptrCast(@alignCast(context));
        if (!std.mem.eql(u8, hash, repeat("b", 40))) return null;
        return .{ .baseline_hash = null, .files = self.files };
    }
};

fn completeTestArtifact(gpa: std.mem.Allocator, body: []const u8) ![]u8 {
    var checksum_buf: [32]u8 = undefined;
    const checksum = artifact.selfChecksum(body, &checksum_buf);
    return std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ body, checksum });
}

fn putTestArtifact(log: *logstore.LogStore, name: []const u8, bytes: []const u8) !void {
    var digest: [logstore.max_hash_len]u8 = undefined;
    const raw = try decodeValidatedName(name, &digest);
    _ = try log.put(raw, bytes);
}

fn putCompleteTestArtifact(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    body: []const u8,
    id_buf: *[64]u8,
) ![]const u8 {
    const bytes = try completeTestArtifact(gpa, body);
    defer gpa.free(bytes);
    const id = artifact.artifactName(.sha1, bytes, id_buf);
    try putTestArtifact(log, id, bytes);
    return id;
}

fn putTestPayload(
    log: *logstore.LogStore,
    bytes: []const u8,
    id_buf: *[64]u8,
) ![]const u8 {
    const id = artifact.artifactName(.sha1, bytes, id_buf);
    try putTestArtifact(log, id, bytes);
    return id;
}

fn expectFileState(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    path: []const u8,
    expected_bytes: []const u8,
    expected_mode: artifact.FileMode,
) !void {
    const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false });
    try std.testing.expectEqual(.file, stat.kind);
    try std.testing.expect(modeMatches(stat, expected_mode));

    var file = try worktree.openFile(io, path, .{
        .allow_directory = false,
        .follow_symlinks = false,
        .resolve_beneath = true,
    });
    defer file.close(io);
    const bytes = try gpa.alloc(u8, stat.size);
    defer gpa.free(bytes);
    const n = try file.readPositionalAll(io, bytes, 0);
    try std.testing.expectEqual(expected_bytes.len, n);
    try std.testing.expectEqualStrings(expected_bytes, bytes);
}

fn expectSymlinkTarget(io: std.Io, worktree: std.Io.Dir, path: []const u8, expected_target: []const u8) !void {
    const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false });
    try std.testing.expectEqual(.sym_link, stat.kind);

    var target: [1024]u8 = undefined;
    const n = try worktree.readLink(io, path, &target);
    try std.testing.expectEqualStrings(expected_target, target[0..n]);
}

const RootEntrySet = struct {
    names: std.ArrayList([]u8) = .empty,

    fn deinit(self: *RootEntrySet, gpa: std.mem.Allocator) void {
        for (self.names.items) |name| gpa.free(name);
        self.names.deinit(gpa);
        self.* = undefined;
    }

    fn contains(self: *const RootEntrySet, name: []const u8) bool {
        for (self.names.items) |candidate| {
            if (std.mem.eql(u8, candidate, name)) return true;
        }
        return false;
    }
};

fn snapshotRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) !RootEntrySet {
    var entries = RootEntrySet{};
    errdefer entries.deinit(gpa);
    var root = try worktree.openDir(io, ".", .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false });
    defer root.close(io);
    var iterator = root.iterate();
    while (try iterator.next(io)) |entry| {
        const name = try gpa.dupe(u8, entry.name);
        entries.names.append(gpa, name) catch |err| {
            gpa.free(name);
            return err;
        };
    }
    return entries;
}

fn expectSameRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, expected: *const RootEntrySet) !void {
    var actual = try snapshotRootEntries(gpa, io, worktree);
    defer actual.deinit(gpa);
    try std.testing.expectEqual(expected.names.items.len, actual.names.items.len);
    for (expected.names.items) |name| try std.testing.expect(actual.contains(name));
}

fn setupMutatingFailureFixture(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    target_id_buf: *[64]u8,
    old_id_buf: *[64]u8,
) ![]const u8 {
    var old_payload_id_buf: [64]u8 = undefined;
    const old_payload_id = try putTestPayload(log, "old executable bytes", &old_payload_id_buf);
    var target_payload_id_buf: [64]u8 = undefined;
    const target_payload_id = try putTestPayload(log, "new regular bytes", &target_payload_id_buf);

    const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF old-file {s} x\n", .{old_payload_id});
    defer gpa.free(old_body);
    const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);

    const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF new-file {s}\n", .{target_payload_id});
    defer gpa.free(target_body);
    const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);

    try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    var old_managed = managed.ManagedSet.init(gpa);
    defer old_managed.deinit();
    _ = try old_managed.insert("old-file");
    try old_managed.write(io, worktree);
    try writePayload(io, worktree, "old-file", "old executable bytes", .executable, true, null, true, null);
    return target_id;
}

fn setupPathTransitionFixture(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    old_path: []const u8,
    target_path: []const u8,
    target_id_buf: *[64]u8,
    old_id_buf: *[64]u8,
) ![]const u8 {
    var old_payload_id_buf: [64]u8 = undefined;
    const old_payload_id = try putTestPayload(log, "old bytes", &old_payload_id_buf);
    var target_payload_id_buf: [64]u8 = undefined;
    const target_payload_id = try putTestPayload(log, "target bytes", &target_payload_id_buf);
    const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF {s} {s}\n", .{ old_path, old_payload_id });
    defer gpa.free(old_body);
    const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);
    const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF {s} {s}\n", .{ target_path, target_payload_id });
    defer gpa.free(target_body);
    const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);

    try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    var old_managed = managed.ManagedSet.init(gpa);
    defer old_managed.deinit();
    _ = try old_managed.insert(old_path);
    try old_managed.write(io, worktree);
    try writePayload(io, worktree, old_path, "old bytes", .regular, true, null, true, null);
    return target_id;
}

test "update transaction creation leaves no directory on name allocation failure" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
    defer entries_before.deinit(t.allocator);
    var failing_allocator = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0 });

    try t.expectError(error.OutOfMemory, createTransaction(failing_allocator.allocator(), io, tmp.dir));
    try t.expect(failing_allocator.has_induced_failure);
    try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
}

test "update rollback removes created ancestors before restoring an old file" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);
    var target_id_buf: [64]u8 = undefined;
    var old_id_buf: [64]u8 = undefined;
    const target_id = try setupPathTransitionFixture(
        t.allocator,
        io,
        tmp.dir,
        &log,
        "a",
        "a/b/c",
        &target_id_buf,
        &old_id_buf,
    );
    var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
    defer entries_before.deinit(t.allocator);

    test_fault = .after_materialize;
    defer test_fault = null;
    try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));

    try expectFileState(t.allocator, io, tmp.dir, "a", "old bytes", .regular);
    try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("a"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(old_id_buf[0..40], state.version);
    try t.expectEqualStrings("old-branch", state.branch);
}

test "update replaces managed directory hierarchy with a file" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);
    var target_id_buf: [64]u8 = undefined;
    var old_id_buf: [64]u8 = undefined;
    const target_id = try setupPathTransitionFixture(
        t.allocator,
        io,
        tmp.dir,
        &log,
        "a/b/c",
        "a",
        &target_id_buf,
        &old_id_buf,
    );

    try applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit);

    try expectFileState(t.allocator, io, tmp.dir, "a", "target bytes", .regular);
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("a"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(target_id, state.version);
    try t.expectEqualStrings("trunk", state.branch);
}
test "update restores mutated bytes mode managed set and checkout after pre-commit failure" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);
    var target_id_buf: [64]u8 = undefined;
    var old_id_buf: [64]u8 = undefined;
    const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);

    test_fault = .after_materialize;
    defer test_fault = null;
    try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));

    try expectFileState(t.allocator, io, tmp.dir, "old-file", "old executable bytes", .executable);
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "new-file", .{ .follow_symlinks = false }));
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("old-file"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(old_id_buf[0..40], state.version);
    try t.expectEqualStrings("old-branch", state.branch);
}

test "update rolls a materialized symlink back after a later failure" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    var old_payload_id_buf: [64]u8 = undefined;
    const old_payload_id = try putTestPayload(&log, "old regular bytes\n", &old_payload_id_buf);
    var target_payload_id_buf: [64]u8 = undefined;
    const target_payload_id = try putTestPayload(&log, "../new-target", &target_payload_id_buf);

    const old_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF entry {s}\n", .{old_payload_id});
    defer t.allocator.free(old_body);
    var old_id_buf: [64]u8 = undefined;
    const old_id = try putCompleteTestArtifact(t.allocator, &log, old_body, &old_id_buf);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF entry {s} l\n", .{target_payload_id});
    defer t.allocator.free(target_body);
    var target_id_buf: [64]u8 = undefined;
    const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);

    try checkout.write(io, tmp.dir, .{ .version = old_id, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    var old_managed = managed.ManagedSet.init(t.allocator);
    defer old_managed.deinit();
    _ = try old_managed.insert("entry");
    try old_managed.write(io, tmp.dir);
    try writePayload(io, tmp.dir, "entry", "old regular bytes\n", .regular, true, null, true, null);
    var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
    defer entries_before.deinit(t.allocator);

    test_fault = .after_materialize;
    defer test_fault = null;
    try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));

    try expectFileState(t.allocator, io, tmp.dir, "entry", "old regular bytes\n", .regular);
    try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("entry"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(old_id, state.version);
}

test "update retains the committed checkout after post-checkout cleanup failure" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);
    var target_id_buf: [64]u8 = undefined;
    var old_id_buf: [64]u8 = undefined;
    const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);

    test_fault = .before_cleanup;
    defer test_fault = null;
    try t.expectError(error.CleanupFailed, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));

    try expectFileState(t.allocator, io, tmp.dir, "new-file", "new regular bytes", .regular);
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "old-file", .{ .follow_symlinks = false }));
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("new-file"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(target_id, state.version);
    try t.expectEqualStrings("trunk", state.branch);
}

test "update delta expansion skips unmatched delete markers" {
    const files = [_]artifact.FCard{
        .{ .filename = "gone", .hash = null, .perm = null, .old_name = null, .mode = .regular },
        .{ .filename = "kept", .hash = repeat("a", 40), .perm = null, .old_name = null, .mode = .regular },
    };
    var baseline = TestBaseline{ .files = &.{} };
    var walk = try artifact.enumerate(.{ .baseline_hash = repeat("b", 40), .files = &files }, .{
        .ctx = @ptrCast(&baseline),
        .resolveFn = TestBaseline.resolve,
    });
    var materialized: usize = 0;
    while (walk.next()) |file| {
        if (file.hash == null) continue;
        materialized += 1;
        try std.testing.expectEqualStrings("kept", file.filename);
    }
    try std.testing.expectEqual(@as(usize, 1), materialized);
}

// ---------------------------------------------------------------------------
// draft-fossil-submanifests-00 §5 — `update` resolves through commit's resolver
// ---------------------------------------------------------------------------
//
// The Appendix A.2/A.3 vectors, byte-for-byte, stored as real artifacts so the
// shared §5.2 resolver does the resolving over the LogStore this module holds.
// `buildPlan` is the seam: it is the whole read side of `applyCheckin`, and it
// needs no worktree, so these pin the walk without a materialization fixture.

const sm_a2_1_leaf = rfc_vectors.submanifest_a2_1_leaf;
const sm_a2_2_leaf = rfc_vectors.submanifest_a2_2_leaf;
const sm_a3_manifest = rfc_vectors.submanifest_a3_tree_manifest;

/// §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c,
/// src/node11.c, src/util.c: the same list a flat manifest would carry."
const sm_a1_filenames = [_][]const u8{ "README.md", "src/main.c", "src/node11.c", "src/util.c" };

/// Store `bytes` under the name they actually hash to, in the 64-hex form the
/// Appendix A vectors' S cards use.
fn putVectorArtifact(log: *logstore.LogStore, bytes: []const u8, id_buf: *[64]u8) ![]const u8 {
    const id = artifact.artifactName(.sha3_256, bytes, id_buf);
    try putTestArtifact(log, id, bytes);
    return id;
}

/// Respell every S card's hash token in UPPERCASE and reseal the Z card. §3.5
/// admits both spellings — "exactly 40 or 64 case-insensitive hexadecimal
/// characters" — so the result is a legal tree manifest naming the very same
/// two sub-manifests.
fn upperCaseSCardHashes(gpa: std.mem.Allocator, manifest_bytes: []const u8) ![]u8 {
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    var lines = std.mem.splitScalar(u8, manifest_bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        if (line[0] == 'Z') continue; // resealed below over the rewritten body
        if (line[0] == 'S') {
            const gap = std.mem.lastIndexOfScalar(u8, line, ' ').?;
            try body.appendSlice(gpa, line[0 .. gap + 1]);
            for (line[gap + 1 ..]) |c| try body.append(gpa, std.ascii.toUpper(c));
        } else {
            try body.appendSlice(gpa, line);
        }
        try body.append(gpa, '\n');
    }
    return completeTestArtifact(gpa, body.items);
}

// §3.5 makes an S card's hash case-insensitive, and `parseSCard` admits both
// spellings on purpose. `update` used to carry its own resolver, which decoded
// the token through `validateName` — canonical lowercase only — and so refused
// a tree that `commit`'s resolver walks without complaint: a legal artifact
// turned into a spurious §5.2 hard failure. Routing both through one resolver
// is what removed that; this pins it so a second copy cannot reintroduce it.
test "update submanifests §3.5: an uppercase S-card hash resolves, as commit's resolver already does" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    var leaf_1_buf: [64]u8 = undefined;
    _ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf);
    var leaf_2_buf: [64]u8 = undefined;
    _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);

    const upper = try upperCaseSCardHashes(t.allocator, sm_a3_manifest);
    defer t.allocator.free(upper);
    // The rewrite really did change the spelling and nothing else about the run.
    try t.expect(!std.mem.eql(u8, upper, sm_a3_manifest));
    try t.expect(std.mem.indexOf(u8, upper, "S README.md AA9D404C") != null);

    var tree_buf: [64]u8 = undefined;
    const tree_id = try putVectorArtifact(&log, upper, &tree_buf);

    var plan = try buildPlan(t.allocator, &log, tree_id, null);
    defer plan.deinit(t.allocator);

    try t.expectEqual(sm_a1_filenames.len, plan.entries.items.len);
    for (sm_a1_filenames, plan.entries.items) |want, entry| {
        try t.expectEqualStrings(want, entry.path);
    }
}

// The reverse guarantee of the same unification: routing through the shared
// resolver must not blur a real store fault into §5.2's resolution gap. The
// log source renames `Storage` to `CannotRead` on the way out and `flattenIfTree`
// renames it back, so every code an `ApplyError` consumer could branch on
// survives the trip. Corrupt is the one with an observable trigger: bytes
// stored under a name they do not hash to.
test "update submanifests §5.2: a sub-manifest stored under another name is Corrupt, not a resolution gap" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    // The first S card's target is present and parses — but the bytes filed
    // under its name are the *other* leaf's, so the name does not describe them.
    var leaf_1_buf: [64]u8 = undefined;
    const leaf_1_id = artifact.artifactName(.sha3_256, sm_a2_1_leaf, &leaf_1_buf);
    try putTestArtifact(&log, leaf_1_id, sm_a2_2_leaf);
    var leaf_2_buf: [64]u8 = undefined;
    _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);
    var tree_buf: [64]u8 = undefined;
    const tree_id = try putVectorArtifact(&log, sm_a3_manifest, &tree_buf);

    // Corrupt, not BaselineNotFound: §5.2's hard failure is about resolution,
    // and this repository's integrity is the actual claim being made.
    try t.expectError(error.Corrupt, buildPlan(t.allocator, &log, tree_id, null));
}

test "update bootstrap clean state leaves checkout metadata absent" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    var plan = try requireClean(t.allocator, io, tmp.dir, &log, &current_managed);
    defer plan.deinit(t.allocator);
    try t.expectEqual(@as(usize, 0), plan.entries.items.len);

    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    try t.expectEqual(@as(?checkout.CheckoutState, null), try checkout.read(io, tmp.dir, &checkout_buf));
    try t.expectEqual(@as(usize, 0), current_managed.iterate().len);
}

test "update applies the shared reserved root policy before staging" {
    const Case = struct { path: []const u8, reserved: bool };
    const cases = [_]Case{
        .{ .path = ".zit", .reserved = true },
        .{ .path = ".ZiT-Checkout", .reserved = true },
        .{ .path = ".ZiT-CaChE/repo", .reserved = true },
        .{ .path = ".zit-tmp-txn-user/file", .reserved = true },
        .{ .path = ".ZiT-Tmp-anything", .reserved = true },
        .{ .path = ".zit-checkout-user/file", .reserved = false },
        .{ .path = "sub/.zit/object", .reserved = false },
        // The deleted legacy tier: ordinary admissible names now.
        .{ .path = ".zIT-Managed/entries", .reserved = false },
        .{ .path = ".ZiT-VStat/data", .reserved = false },
        .{ .path = ".zit-import-map.jsonl/sidecar", .reserved = false },
        .{ .path = "ReFs/heads/main", .reserved = false },
        .{ .path = ".zit-update-txn-user/file", .reserved = false },
        .{ .path = ".zit-update-txn", .reserved = false },
        .{ .path = ".zit-update-txns/user", .reserved = false },
    };
    for (cases) |case_| {
        if (case_.reserved) {
            try std.testing.expectError(error.ReservedPath, validateWorktreePath(case_.path));
        } else {
            try validateWorktreePath(case_.path);
        }
    }
}
test "update reserved paths leave the plan unchanged" {
    var plan: Plan = .{};
    defer plan.deinit(std.testing.allocator);
    const paths = [_][]const u8{ ".ZiT-Checkout", ".zit-tmp-txn-user/file" };
    for (paths) |path| {
        const file = artifact.FCard{
            .filename = path,
            .hash = repeat("a", 40),
            .perm = null,
            .old_name = null,
            .mode = .regular,
        };
        try std.testing.expectError(error.ReservedPath, plan.add(std.testing.allocator, file));
        try std.testing.expectEqual(@as(usize, 0), plan.entries.items.len);
    }
}

test "update rejects malformed worktree paths before staging" {
    try std.testing.expectError(error.InvalidPath, validateWorktreePath("../outside"));
    try std.testing.expectError(error.InvalidPath, validateWorktreePath("a//b"));
}

test "update state assembly rejects a manifest stored under another name" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
    defer t.allocator.free(target_body);
    const target_bytes = try completeTestArtifact(t.allocator, target_body);
    defer t.allocator.free(target_bytes);
    var target_id_buf: [64]u8 = undefined;
    const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
    try putTestArtifact(&log, target_id, target_bytes);

    const misnamed_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF other {s}\n", .{repeat("b", 40)});
    defer t.allocator.free(misnamed_body);
    const misnamed_bytes = try completeTestArtifact(t.allocator, misnamed_body);
    defer t.allocator.free(misnamed_bytes);
    var wrong_name_buf: [64]u8 = undefined;
    const wrong_name = artifact.artifactName(.sha1, "a different artifact", &wrong_name_buf);
    try putTestArtifact(&log, wrong_name, misnamed_bytes);

    try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}

test "update state assembly rejects a control stored under another name" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
    defer t.allocator.free(target_body);
    const target_bytes = try completeTestArtifact(t.allocator, target_body);
    defer t.allocator.free(target_bytes);
    var target_id_buf: [64]u8 = undefined;
    const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
    try putTestArtifact(&log, target_id, target_bytes);

    const control_bytes = try completeTestArtifact(t.allocator, "D 2007-07-21T14:10:00\nT +branch cccccccccccccccccccccccccccccccccccccccc trunk\nU alice\n");
    defer t.allocator.free(control_bytes);
    var wrong_name_buf: [64]u8 = undefined;
    const wrong_name = artifact.artifactName(.sha1, "a different control artifact", &wrong_name_buf);
    try putTestArtifact(&log, wrong_name, control_bytes);

    try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}

// The shape that made `zit update` accuse an intact fossil clone of corruption:
// an anonymous wiki page. It is well-framed and its cards parse, but a wiki
// artifact requires a `U` card and an anonymous edit has none, so classification
// fails with `MissingRequiredCard`. Its bytes still hash to the name it is
// stored under, so the repository is undamaged and the artifact is opaque to
// state: assembly skips it and completes.
//
// This is the non-corpus stand-in for the 67,499-artifact fossil clone that
// surfaced the defect, where the live instance is artifact
// 14dc8dab9792f8f0139dc05d49cd0f5cdc55503f, a 2008 edit of the page
// "RockShoxBugs". Nothing here depends on that corpus or on any wall clock —
// the trigger is a card set, so a generator can emit it.
test "update state assembly skips a stored artifact it cannot classify" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
    defer t.allocator.free(target_body);
    var target_id_buf: [64]u8 = undefined;
    const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);

    // D, L, W, Z — no U card, exactly as fossil stores an anonymous wiki edit.
    var wiki_id_buf: [64]u8 = undefined;
    _ = try putCompleteTestArtifact(
        t.allocator,
        &log,
        "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n",
        &wiki_id_buf,
    );

    var assembly = try assembleState(t.allocator, &log, target_id);
    defer assembly.deinit();

    // Skipped, not absorbed: the wiki artifact contributed no state. Assembly
    // resolved the target it was asked for, and the unclassifiable artifact is
    // not among the crosslinked records.
    try t.expect(assembly.state.store.lookup(wiki_id_buf[0..40]) == null);
}

// The other half of the rule, and the reason the arm verifies before it skips.
// Tolerating an artifact this reader declines must not become tolerating one
// whose bytes do not match the name they are stored under. Same wiki card set,
// same classification failure — but stored under a name that is not its hash.
// That is an integrity failure, and it stays fatal.
//
// Without this test the skip above is vacuous: a blanket `continue` on every
// classification failure would pass it and lose the corruption signal entirely.
test "update state assembly still rejects an unclassifiable artifact whose name does not verify" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
    defer t.allocator.free(target_body);
    var target_id_buf: [64]u8 = undefined;
    const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);

    const wiki_bytes = try completeTestArtifact(
        t.allocator,
        "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n",
    );
    defer t.allocator.free(wiki_bytes);
    var wrong_name_buf: [64]u8 = undefined;
    const wrong_name = artifact.artifactName(.sha1, "not these bytes", &wrong_name_buf);
    try putTestArtifact(&log, wrong_name, wiki_bytes);

    try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}

test "update §6.3: a lying R card refuses materialization; the truthful one applies (Fossil's default repo-cksum posture)" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    const io = t.io;

    const blob = "content the R card attests\n";
    var blob_id_buf: [64]u8 = undefined;

    // One fixture builder, two R spellings: the aggregate over the real
    // content, and a hex-flipped lie of it.
    var truth_hex: [32]u8 = undefined;
    {
        var agg: artifact.ChecksumAggregator = .{};
        agg.update("a.txt", blob);
        _ = agg.final(&truth_hex);
    }
    var lie_hex: [32]u8 = truth_hex;
    lie_hex[0] = if (lie_hex[0] == '0') '1' else '0';

    for ([2]struct { r: []const u8, ok: bool }{
        .{ .r = &lie_hex, .ok = false },
        .{ .r = &truth_hex, .ok = true },
    }) |case| {
        var tmp = t.tmpDir(.{});
        defer tmp.cleanup();
        var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
        defer raw_file.close(io);
        var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
        var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
        defer log.deinit(t.allocator);

        const blob_id = try putVectorArtifact(&log, blob, &blob_id_buf);
        const body = try std.fmt.allocPrint(
            t.allocator,
            "C r\nD 2024-01-01T00:00:00\nF a.txt {s}\nR {s}\nU u\n",
            .{ blob_id, case.r },
        );
        defer t.allocator.free(body);
        const sealed = try completeTestArtifact(t.allocator, body);
        defer t.allocator.free(sealed);
        var id_buf: [64]u8 = undefined;
        const id = try putVectorArtifact(&log, sealed, &id_buf);

        if (case.ok) {
            try applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit);
            try expectFileState(t.allocator, io, tmp.dir, "a.txt", blob, .regular);
        } else {
            try t.expectError(
                error.AggregateChecksumMismatch,
                applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit),
            );
            // Fail-closed means fail-early: nothing was materialized.
            try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "a.txt", .{}));
        }
    }
}

test "update: the staging fault survives the plan it came from (end to end)" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    // The tree and its leaves, but NOT the file blobs their F cards name — so
    // the plan builds and staging is the first thing that cannot proceed.
    var leaf_1_buf: [64]u8 = undefined;
    _ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf);
    var leaf_2_buf: [64]u8 = undefined;
    _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);
    var root_buf: [64]u8 = undefined;
    const root_id = try putVectorArtifact(&log, sm_a3_manifest, &root_buf);

    const prior = last_stage_fault;
    defer last_stage_fault = prior;
    last_stage_fault = null;

    const err = applyCheckin(t.allocator, io, tmp.dir, &log, root_id, .{ .set = "" }, .inherit);
    try t.expectError(error.FileContentMissing, err);

    // The whole point, and what a hand-built fault cannot check: `applyCheckin`
    // frees the plan from its own `defer` on the way out, so a fault that
    // BORROWED the plan's path would be read here as freed heap. It printed
    // exactly that — ten bytes of garbage where a filename belonged — until the
    // path was copied instead.
    const f = last_stage_fault orelse return error.TestExpectedFault;
    const path = f.filePath();
    var known = false;
    for (sm_a1_filenames) |name| {
        if (std.mem.eql(u8, path, name)) known = true;
    }
    try t.expect(known);

    // And the artifact name is a real 64-hex id, not whatever was on the stack.
    try t.expectEqual(@as(usize, 64), f.artifactName().len);
    for (f.artifactName()) |c| try t.expect(std.ascii.isHex(c));
}

test "update: a staging fault names the file and the artifact, not just 'corrupt'" {
    const t = std.testing;
    // Narrow by design: this pins the RENDERING and the reason mapping. That
    // `stagePlan` reaches it is enforced by the compiler — `FileContentMissing`
    // is in the closed `ApplyError` set, so every one of the four verb mappers
    // had to grow an arm for it or fail to build.
    const prior = last_stage_fault;
    defer last_stage_fault = prior;

    var f: StageFault = .{ .reason = .not_in_repository };
    const name = repeat("a", 64);
    @memcpy(f.name[0..64], name);
    f.name_len = 64;
    // Copied, never borrowed: the plan that owns the real path is freed by
    // `applyCheckin`'s defer before any caller renders this.
    @memcpy(f.path_buf[0.."src/main.c".len], "src/main.c");
    f.path_len = "src/main.c".len;
    last_stage_fault = f;

    var buf: [512]u8 = undefined;
    const msg = stageFaultReason(&buf);
    // The path answers "which file", the hash answers "which artifact to go
    // look for in the source repository". A bare corruption claim answers
    // neither, which is what made this take minutes to even localise.
    try t.expect(std.mem.indexOf(u8, msg, "src/main.c") != null);
    try t.expect(std.mem.indexOf(u8, msg, name) != null);
    try t.expect(std.mem.indexOf(u8, msg, "is not in this repository") != null);

    last_stage_fault = null;
    const neutral = stageFaultReason(&buf);
    try t.expect(std.mem.indexOf(u8, neutral, "missing") != null);
}

test "update state assembly retains the stable MemStore backing RepoState" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
    defer t.allocator.free(target_body);
    const target_bytes = try completeTestArtifact(t.allocator, target_body);
    defer t.allocator.free(target_bytes);
    var target_id_buf: [64]u8 = undefined;
    const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
    try putTestArtifact(&log, target_id, target_bytes);

    var assembly = try assembleState(t.allocator, &log, target_id);
    defer assembly.deinit();
    try t.expectEqual(@intFromPtr(assembly.mem), @intFromPtr(assembly.state.store.context));

    const added = try assembly.state.crosslink(.{
        .hash = repeat("b", 40),
        .mtime = 1,
    });
    try t.expectEqual(added, assembly.mem.store().lookup(repeat("b", 40)).?);
}

fn setupPreMergeWorktree(io: std.Io, worktree: std.Io.Dir) !void {
    // A clean checkout with no pending merge, plus two managed files.
    try checkout.write(io, worktree, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    try writePayload(io, worktree, "keep-me", "current\n", .regular, true, null, true, null);
    try writePayload(io, worktree, "remove-me", "gone\n", .regular, true, null, true, null);
}

test "merge applyMerge materializes writes and deletes and records the merge parent" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    try setupPreMergeWorktree(io, tmp.dir);

    const other = repeat("2", 64);
    const entries = [_]MergeEntry{
        .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
        .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } },
        .{ .path = "remove-me", .action = .delete },
    };
    try applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = other, .special_op = "", .manifest_flags = "" });

    try expectFileState(t.allocator, io, tmp.dir, "keep-me", "merged\n", .regular);
    // B-merge-23: the current-side "x" attribute is preserved on the merged file.
    try expectFileState(t.allocator, io, tmp.dir, "tool.sh", "#!/bin/sh\n", .executable);
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "remove-me", .{ .follow_symlinks = false }));

    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
    try t.expectEqualStrings(repeat("1", 64), ck.version);
    try t.expectEqualStrings("trunk", ck.branch);
    try t.expectEqualStrings(other, ck.merge_parent);

    // Journal armed before mutating: the undo level is present (B-merge-3).
    const journal_bytes = (try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo)).?;
    t.allocator.free(journal_bytes);
}

test "merge applyMerge journals the pre-merge image so undo clears the pending merge" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    try setupPreMergeWorktree(io, tmp.dir);

    const entries = [_]MergeEntry{
        .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
        .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } },
        .{ .path = "remove-me", .action = .delete },
    };
    try applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = repeat("2", 64), .special_op = "", .manifest_flags = "" });

    // The journal captured the pre-merge tree (files + merge_parent == ""), so
    // undo restores it byte-for-byte and the pending merge disappears (B-merge-13).
    try journal.undo(t.allocator, io, tmp.dir);
    try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular);
    try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular);
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "tool.sh", .{ .follow_symlinks = false }));

    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
    try t.expectEqualStrings(repeat("1", 64), ck.version);
    try t.expectEqualStrings("", ck.merge_parent);
}

test "merge applyMerge rolls files and checkout back on an injected mid-apply failure" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    try setupPreMergeWorktree(io, tmp.dir);

    // Entry 0 (write) is applied; the fault fires before entry 1 (delete), so the
    // tree is partially mutated and rollback must fully restore it.
    const entries = [_]MergeEntry{
        .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
        .{ .path = "remove-me", .action = .delete },
    };
    test_fault = .merge_mid_apply;
    defer test_fault = null;
    try t.expectError(error.Storage, applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = repeat("2", 64), .special_op = "", .manifest_flags = "" }));

    // Files restored, no delete applied, checkout back to no-pending-merge.
    try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular);
    try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular);
    var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
    try t.expectEqualStrings(repeat("1", 64), ck.version);
    try t.expectEqualStrings("", ck.merge_parent);

    // A clean rollback drops the journal — no spurious undo level survives.
    const undo_section = try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo);
    try t.expectEqual(@as(?[]u8, null), undo_section);
}

// Pull the undo/redo journal's inline unit tests (contracts/undo.md) into
// `zig build test`. `undo.zig` and this file import each other; referencing it
// here in the same module keeps a single instance and no duplicate analysis.
test {
    _ = journal;
}

test "update: a legacy trailing-separator F card plans the hash it names" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    // The blob the check-in names, stored under its real sha1 name.
    const blob = "int main(void){return 0;}\n";
    var blob_id_buf: [64]u8 = undefined;
    const blob_id = artifact.artifactName(.sha1, blob, &blob_id_buf);
    try putTestArtifact(&log, blob_id, blob);

    // A manifest whose F card carries the LEGACY TRAILING SEPARATOR — one SP
    // after the hash. This is what fossil-origin history looks like, and it is
    // the only shape that makes `parseForImport` normalize into a fresh buffer:
    // the manifest then borrows THAT copy, not the stored bytes, and every
    // F-card hash the plan reads points into it.
    //
    // Nothing else in this suite exercises that. Every synthetic corpus is
    // git-native and never carries the separator, so `normalized` is null
    // throughout — which left the one path a real fossil-origin import always
    // takes covered only by repositories nobody runs the tests against.
    const body = try std.fmt.allocPrint(
        t.allocator,
        "D 2007-07-21T14:09:59\nF src/expr.c {s} \n",
        .{blob_id},
    );
    defer t.allocator.free(body);
    const bytes = try completeTestArtifact(t.allocator, body);
    defer t.allocator.free(bytes);
    var id_buf: [64]u8 = undefined;
    const id = artifact.artifactName(.sha1, bytes, &id_buf);
    try putTestArtifact(&log, id, bytes);

    var plan = try buildPlan(t.allocator, &log, id, null);
    defer plan.deinit(t.allocator);

    try t.expectEqual(@as(usize, 1), plan.entries.items.len);
    const entry = plan.entries.items[0];
    try t.expectEqualStrings("src/expr.c", entry.path);
    // The whole question: does the plan carry the name the F card wrote?
    try t.expectEqualStrings(blob_id, entry.name);
}

// --- Foreign check-ins -----------------------------------------------------
// The format types artifacts by STRUCTURE (artifact-format §5.1 L1483), and
// repo-state §4.4 L634-642 exempts only what a parser DECLINES to classify. So
// a stored blob whose bytes are a valid check-in manifest IS a check-in of this
// repository's DAG, even though its content came from somewhere else — a
// Fossil→Git mirror's tracked `manifest` file being the case that found this.
//
// Both tests below assert CONFORMANCE, not a defect: the derivation is required
// to behave this way. What must never regress is what the CONSUMERS do about it.

test "update: a foreign check-in that carries its own branch tag is refused, not materialized" {
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    // A check-in naming a file whose blob this repository never held — the
    // shape a mirror's `manifest` has, since its F cards name the FOREIGN
    // repository's artifacts. `T *branch * trunk` is a self-targeted,
    // value-carrying propagating tag (§4.1: the UUID field is an artifact name
    // or the literal `*`; `T *branch trunk` puts a branch name in the UUID
    // field and the whole manifest is rejected instead).
    const body = try std.fmt.allocPrint(
        t.allocator,
        "C foreign\nD 2099-01-01T00:00:00\nF src/expr.c {s}\nT *branch * trunk\nT *sym-trunk *\nU drh\n",
        .{repeat("7e", 20)},
    );
    defer t.allocator.free(body);
    const bytes = try completeTestArtifact(t.allocator, body);
    defer t.allocator.free(bytes);
    var id_buf: [64]u8 = undefined;
    const id = artifact.artifactName(.sha1, bytes, &id_buf);
    try putTestArtifact(&log, id, bytes);

    const prior = last_stage_fault;
    defer last_stage_fault = prior;
    last_stage_fault = null;

    // Materializing it must FAIL, and fail before touching the working tree.
    // `stagePlan` verifies and stages every byte into the transaction directory
    // before any worktree mutation, which is why a plan naming absent content
    // cannot leave a half-written tree behind.
    try t.expectError(
        error.FileContentMissing,
        applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit),
    );

    const f = last_stage_fault orelse return error.TestExpectedFault;
    try t.expectEqualStrings("src/expr.c", f.filePath());

    // FAIL-CLOSED, stated as an assertion so it cannot be traded away later:
    // nothing the check-in named was written.
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "src/expr.c", .{}));
}

test "update: the refusal explains the class, not just the missing path" {
    const t = std.testing;
    // A path and a hash alone send someone hunting for a corrupt artifact. The
    // cause is that the repository holds a check-in belonging to a different
    // repository, which is not guessable from either.
    try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "ANOTHER repository") != null);
    try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "mirror") != null);
    // And it must not allege damage: the store is intact and `verify` will say so.
    try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "corrupt") == null);
}

/// A `CarryResolver` for the in-module tests: answers with a fixed carry set.
const FixedCarryResolver = struct {
    answer: []const Carry,

    fn resolver(self: *FixedCarryResolver) CarryResolver {
        return .{ .ctx = self, .resolve = resolve };
    }

    fn resolve(
        ctx: *anyopaque,
        divergences: []const Divergence,
    ) error{ OutOfMemory, Refused }![]const Carry {
        _ = divergences;
        const self: *FixedCarryResolver = @ptrCast(@alignCast(ctx));
        return self.answer;
    }
};

test "a carried update rolls back to the WORKING bytes, not the check-in's" {
    // The invariant that makes `Carry` name two endpoints instead of one.
    //
    // A carrying update writes merged bytes over a file the operator has
    // uncommitted edits in. If it fails partway, the staged `previous/` tree is
    // what gets restored — and if that tree held the CHECK-IN's content (which
    // is what the baseline plan describes) the rollback would quietly replace
    // uncommitted work with committed bytes. A failed update destroying the
    // edits it was asked to preserve is the same class of loss the carry rule exists
    // to prevent, and it would leave no trace: the update reports an error, the
    // tree looks plausible, and the work is gone.
    //
    // So `overlayCarry` puts `before` into the baseline plan as well, and this
    // is the test that the two endpoints are actually wired to their two
    // destinations rather than both to the same one.
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    var v1_buf: [64]u8 = undefined;
    const v1 = try putTestPayload(&log, "committed base", &v1_buf);
    var v2_buf: [64]u8 = undefined;
    const v2 = try putTestPayload(&log, "committed target", &v2_buf);
    const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1});
    defer t.allocator.free(base_body);
    var base_buf: [64]u8 = undefined;
    const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf);
    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2});
    defer t.allocator.free(target_body);
    var target_buf: [64]u8 = undefined;
    const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf);

    try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit);

    // The operator's uncommitted work. Deliberately different from BOTH
    // check-ins, so a rollback that restored either one would be caught.
    const working_bytes = "uncommitted local work";
    try tmp.dir.deleteFile(io, "moving");
    {
        var f = try tmp.dir.createFile(io, "moving", .{});
        defer f.close(io);
        try f.writePositionalAll(io, working_bytes, 0);
    }

    const carry = [_]Carry{.{
        .path = "moving",
        .after = .{ .bytes = "merged bytes that must not survive" },
        .before = .{ .bytes = working_bytes },
    }};
    var fixed = FixedCarryResolver{ .answer = &carry };

    // Fail AFTER the merged bytes have been materialized — the only window in
    // which the rollback has real work to undo.
    test_fault = .after_materialize;
    defer test_fault = null;
    try t.expectError(error.Storage, applyCheckinWith(
        t.allocator,
        io,
        tmp.dir,
        &log,
        target,
        .inherit,
        .inherit,
        null,
        fixed.resolver(),
    ));

    // The operator's bytes are back. NOT "committed base" (what the baseline
    // plan describes and what a one-endpoint carry would have restored), and
    // NOT the merged bytes the failed update wrote.
    try expectFileState(t.allocator, io, tmp.dir, "moving", working_bytes, .regular);

    // And the checkout did not move, so the failed update is invisible except
    // for its error.
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(base, state.version);
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
    try t.expect(current_managed.contains("moving"));
}

test "a carried update rolls a locally-added file back to existing, not to absent" {
    // The membership half of the same invariant. A path the checkout manages
    // but neither check-in has (`zit add`, not yet committed) is carried into
    // the TARGET plan so the move does not delete it — which also makes it a
    // path the target plan creates. A rollback that reasoned only from the
    // target would therefore delete it as "a file this update created",
    // destroying an add the operator made before the update ran.
    const t = std.testing;
    const test_storage = @import("storage.zig");
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
    defer raw_file.close(io);
    var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
    var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
    defer log.deinit(t.allocator);

    var v1_buf: [64]u8 = undefined;
    const v1 = try putTestPayload(&log, "committed base", &v1_buf);
    var v2_buf: [64]u8 = undefined;
    const v2 = try putTestPayload(&log, "committed target", &v2_buf);
    const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1});
    defer t.allocator.free(base_body);
    var base_buf: [64]u8 = undefined;
    const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf);
    const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2});
    defer t.allocator.free(target_body);
    var target_buf: [64]u8 = undefined;
    const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf);

    try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit);

    const added_bytes = "a file the operator added but has not committed";
    {
        var f = try tmp.dir.createFile(io, "added", .{});
        defer f.close(io);
        try f.writePositionalAll(io, added_bytes, 0);
    }
    {
        var set = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
        defer set.deinit();
        _ = try set.insert("added");
        try set.write(io, tmp.dir);
    }

    // Neither side of the merge has an opinion about `added`, so it is carried
    // unchanged: same bytes at both endpoints.
    const carry = [_]Carry{.{
        .path = "added",
        .after = .{ .bytes = added_bytes },
        .before = .{ .bytes = added_bytes },
    }};
    var fixed = FixedCarryResolver{ .answer = &carry };

    test_fault = .after_materialize;
    defer test_fault = null;
    try t.expectError(error.Storage, applyCheckinWith(
        t.allocator,
        io,
        tmp.dir,
        &log,
        target,
        .inherit,
        .inherit,
        null,
        fixed.resolver(),
    ));

    // Still there, with its bytes, and still managed.
    try expectFileState(t.allocator, io, tmp.dir, "added", added_bytes, .regular);
    var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
    defer current_managed.deinit();
    try t.expect(current_managed.contains("added"));
    try t.expect(current_managed.contains("moving"));
    var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
    try t.expectEqualStrings(base, state.version);
}

test "ensureEmptyDirs creates each listed directory, warns instead of replacing" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var buf: [512]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);

    // An empty list touches nothing and says nothing.
    ensureEmptyDirs(io, tmp.dir, "", &w);
    try std.testing.expectEqualStrings("", w.buffered());

    // Two patterns, the tokenizer's whitespace split; parents come with them.
    ensureEmptyDirs(io, tmp.dir, "a/b/c  tmp/cache", &w);
    try std.testing.expectEqualStrings("", w.buffered());
    var a = try tmp.dir.openDir(io, "a/b/c", .{});
    a.close(io);
    var t = try tmp.dir.openDir(io, "tmp/cache", .{});
    t.close(io);

    // Idempotent: an existing directory is left alone and warns about nothing.
    ensureEmptyDirs(io, tmp.dir, "a/b/c", &w);
    try std.testing.expectEqualStrings("", w.buffered());

    // A file in the way is never replaced — one warning, the file survives.
    try tmp.dir.writeFile(io, .{ .sub_path = "occupied", .data = "mine\n" });
    ensureEmptyDirs(io, tmp.dir, "occupied", &w);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "'occupied' exists and is not a directory") != null);
    const kept = try tmp.dir.readFileAlloc(io, "occupied", std.testing.allocator, .limited(64));
    defer std.testing.allocator.free(kept);
    try std.testing.expectEqualStrings("mine\n", kept);

    // A versioned value must not be able to mint a control name or escape.
    w = std.Io.Writer.fixed(&buf);
    ensureEmptyDirs(io, tmp.dir, ".zit-tmp-x ../outside", &w);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), ".zit-tmp-x") != null);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "../outside") != null);
    try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, ".zit-tmp-x", .{}));
}