Skip to content
zit Artifact [3de1ac052a]
Login

Artifact [3de1ac052a]

Artifact 3de1ac052aa4f3488247b1d7615182e7f1c718cb277640580f2871d3a26ef248:


//! 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 artifact = @import("artifact.zig");
const checkout = @import("checkout.zig");
const logstore = @import("logstore.zig");
const managed = @import("managed.zig");
const repository = @import("repository.zig");
const repostate = @import("repostate.zig");
const control_paths = @import("control_paths.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) {
    inherit,
    set: []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{
    /// 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,
    CheckinNameMismatch,
    TargetNotManifest,
    BaselineNotFound,
    BaselineContentMissing,
    BaselineNameMismatch,
    BaselineNotManifest,
    BaselineIsDelta,
    /// 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,

    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,

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

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

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.
pub fn applyCheckin(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    target_id: []const u8,
    manifest: ManifestOpt,
) ApplyError!void {
    var target = try buildPlan(gpa, log, target_id);
    defer target.deinit(gpa);

    // 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_flags = switch (manifest) {
        .set => |f| 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 "";
        },
    };

    var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
    defer old_managed.deinit();
    var old = try requireClean(gpa, io, worktree, log, &old_managed);
    defer old.deinit(gpa);

    var facts = try deriveTargetFacts(gpa, log, target_id, std.mem.indexOfScalar(u8, manifest_flags, 't') != null);
    defer facts.deinit(gpa);
    const branch = facts.branch;

    var desired_managed = managed.ManagedSet.init(gpa);
    defer desired_managed.deinit();
    for (target.entries.items) |entry| {
        _ = desired_managed.insert(entry.path) catch |err| return mapManagedError(err);
    }
    // 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);

    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 durably staged in this same-filesystem transaction dir.
    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_managed, &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_flags,
    }, .{ .log = log, .target_id = target_id, .tags = facts.tags }) 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;
    };

    // `.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_managed: *const managed.ManagedSet,
    target: *const Plan,
) ApplyError!void {
    var affected = managed.ManagedSet.init(gpa);
    defer affected.deinit();
    for (old_managed.iterate()) |path| _ = affected.insert(path) catch |err| return mapManagedError(err);
    for (target.entries.items) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err);

    // The affected set is `old_managed ∪ target`, so it can never be smaller
    // than either operand: every path this update writes or deletes is journaled
    // before the first working byte moves (B-undo-3/14).
    std.debug.assert(affected.iterate().len >= target.entries.items.len);
    std.debug.assert(affected.iterate().len >= old_managed.iterate().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,
    };
}

fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) 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,
        error.BaselineIsDelta => return error.BaselineIsDelta,
    };

    var result = Plan{};
    errdefer result.deinit(gpa);
    while (walk.next()) |file| {
        if (file.hash == null) continue;
        try result.add(gpa, file);
    }
    try result.validateTopology();
    return result;
}

/// 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 (#36).
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);
}

fn requireClean(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    log: *logstore.LogStore,
    current_managed: *const managed.ManagedSet,
) 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);

    // 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) 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 plan = buildPlan(gpa, log, current.?.version) catch |err| switch (err) {
        error.OutOfMemory, error.Storage, error.Corrupt => return err,
        else => return error.CurrentStateInvalid,
    };
    errdefer plan.deinit(gpa);

    if (!sameMembership(&plan, current_managed)) return error.WorktreeNotClean;
    for (plan.entries.items) |entry| {
        if (!try pathMatchesEntry(gpa, io, worktree, log, &entry)) return error.WorktreeNotClean;
    }
    // Postcondition: a returned plan is the current check-in's, and its
    // membership equals the recorded managed set — the guard above rejected any
    // drift, so on every success path the record and the managed set agree.
    std.debug.assert(sameMembership(&plan, current_managed));
    return plan;
}

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

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

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

    var tags: []u8 = &.{};
    if (want_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.
        const sym_prefix = "sym-";
        const entries = assembly.state.tagsOf(gpa, assembly.target_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;
        }
        tags = out.toOwnedSlice(gpa) catch return error.OutOfMemory;
    }
    return .{ .branch = owned_branch, .tags = tags };
}

/// 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);
    var raw_name: [64]u8 = undefined;
    for (persisted_rids) |rid| {
        const name = encodeName(log.hashOf(rid), &raw_name);
        const bytes = log.content(gpa, rid) 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 = [_]u8{0} ** path_max,
    path_len: u16 = 0,
    /// Also copied, for the same reason: `PlanEntry.name` is owned by the plan.
    name: [64]u8 = [_]u8{0} ** 64,
    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;
}

fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void {
    for (plan.entries.items) |entry| {
        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);
    }
}

fn preflight(
    gpa: std.mem.Allocator,
    io: std.Io,
    worktree: std.Io.Dir,
    target: *const Plan,
    current_managed: *const managed.ManagedSet,
) ApplyError!void {
    for (target.entries.items) |entry| {
        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];
            const existing = try statExisting(io, worktree, parent);
            switch (existing) {
                .missing => {},
                .stat => |stat| switch (stat.kind) {
                    .directory => {},
                    .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 statExisting(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,
) 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);
    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);
    desired_managed.write(io, worktree) catch |err| return mapManagedError(err);
    checkout.write(io, worktree, 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 = 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);
            },
            '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);
            },
            't' => try writePayload(io, worktree, path, src.tags, .regular, false, 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);
    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| {
        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,
        };
    }
}

fn materializePlan(
    gpa: std.mem.Allocator,
    io: std.Io,
    stage: std.Io.Dir,
    worktree: std.Io.Dir,
    plan: *const Plan,
    created_directories: ?*CreatedDirectories,
) ApplyError!void {
    for (plan.entries.items) |entry| {
        try prepareDestination(io, worktree, entry.path);
        const source_parent = openParentNoFollow(io, stage, entry.path, false, null) catch |err| switch (err) {
            error.PathMissing => return error.Storage,
            else => return @errorCast(err),
        };
        defer source_parent.close(io);

        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);
            },
            .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);
            },
        }
    }
}

fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void {
    const existing = try statExisting(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,
        },
    }
}

fn writePayload(
    io: std.Io,
    root: std.Io.Dir,
    path: []const u8,
    bytes: []const u8,
    mode: artifact.FileMode,
    exclusive: bool,
    created_directories: ?*CreatedDirectories,
) ApplyError!void {
    const parent = 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);
            file.writePositionalAll(io, bytes, 0) catch return error.Storage;
            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) 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.
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,
    new_checkout: checkout.CheckoutState,
) ApplyError!void {
    for (entries) |entry| try validateWorktreePath(entry.path);

    // (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 FIRST (checkout carries merge_parent = other),
    //     then the membership it implies. A failure here has moved no working
    //     byte, but the checkout may already name the merge, so roll both back.
    checkout.write(io, worktree, new_checkout) catch |err| return mapCheckoutError(err);
    new_managed.write(io, worktree) catch |err| {
        rollbackMerge(io, worktree, &.{}, old_checkout, &old_managed) catch return error.RollbackFailed;
        journal.clear(io, worktree);
        return mapManagedError(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);
            },
            .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.
    old_managed.write(io, worktree) catch |err| return mapManagedError(err);
    if (old_checkout) |ck| {
        checkout.write(io, worktree, ck) catch |err| return mapCheckoutError(err);
    } else {
        worktree.deleteFile(io, checkout.filename) catch |err| switch (err) {
            error.FileNotFound => {},
            else => return error.Storage,
        };
    }
}

/// 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 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 {
    const parent = 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);
        var name: [41]u8 = undefined;
        const prefix = ".zit-update-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] == '/';
}

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, "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);
    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);
    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));

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

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

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

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

    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 = "a" ** 40, .perm = null, .old_name = null, .mode = .regular },
    };
    var baseline = TestBaseline{ .files = &.{} };
    var walk = try artifact.enumerate(.{ .baseline_hash = "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 = @embedFile("rfc_vectors/submanifest_a2_1_leaf.txt");
const sm_a2_2_leaf = @embedFile("rfc_vectors/submanifest_a2_2_leaf.txt");
const sm_a3_manifest = @embedFile("rfc_vectors/submanifest_a3_tree_manifest.txt");

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

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-Managed/entries", .reserved = true },
        .{ .path = ".zit-import-map.jsonl/sidecar", .reserved = true },
        .{ .path = "ReFs/heads/main", .reserved = true },
        .{ .path = ".zit-update-txn-user/file", .reserved = true },
        .{ .path = ".ZiT-UpDaTe-TxN-user/file", .reserved = true },
        .{ .path = ".zit-checkout-user/file", .reserved = false },
        .{ .path = ".zit-update-txn", .reserved = false },
        .{ .path = ".zit-update-txns/user", .reserved = false },
        .{ .path = "sub/.zit/object", .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-update-txn-user/file" };
    for (paths) |path| {
        const file = artifact.FCard{
            .filename = path,
            .hash = "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", .{"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", .{"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", .{"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", .{"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", .{"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: 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 = "" });
    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 = "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", .{"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 = "b" ** 40,
        .mtime = 1,
    });
    try t.expectEqual(added, assembly.mem.store().lookup("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 = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
    try writePayload(io, worktree, "keep-me", "current\n", .regular, true, null);
    try writePayload(io, worktree, "remove-me", "gone\n", .regular, 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 = "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, .{ .version = "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("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).
    _ = try tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false });
}

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, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "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("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, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "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("1" ** 64, ck.version);
    try t.expectEqualStrings("", ck.merge_parent);

    // A clean rollback drops the journal — no spurious undo level survives.
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false }));
}

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