Skip to content
zit Artifact [6936029d7e]
Login

Artifact [6936029d7e]

Artifact 6936029d7e5caec7e110955bcd0e0db2d43d7ae3631ad464b1f6efa9ef90deec:


//! 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 zit = @import("zit");
const artifact = zit.artifact;
const checkout = @import("zit_checkout");
const logstore = @import("zit_logstore");
const managed = zit.managed;
const repostate = zit.repostate;

/// Errors intentionally describe the caller-visible update decision rather
/// than leaking individual `std.Io` platform errors.
pub const ApplyError = error{
    InvalidCheckinId,
    CheckinNotFound,
    CheckinContentMissing,
    CheckinNameMismatch,
    TargetNotManifest,
    BaselineNotFound,
    BaselineContentMissing,
    BaselineNameMismatch,
    BaselineNotManifest,
    BaselineIsDelta,
    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,
};

// 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;
        if (file.mode == .symlink) return error.SymlinkUnsupported;
        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,

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

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

const StateAssembly = struct {
    gpa: std.mem.Allocator,
    mem: *repostate.MemStore,
    state: repostate.RepoState,
    target_rid: repostate.Rid,

    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,
) ApplyError!void {
    var target = try buildPlan(gpa, log, target_id);
    defer target.deinit(gpa);

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

    const branch = try deriveTargetBranch(gpa, log, target_id);
    defer gpa.free(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);
    }

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

    commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{
        .version = target_id,
        .branch = branch,
    }) 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.
            transaction_live = false;
            return error.RollbackFailed;
        };
        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;
}

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;
        target.manifest = artifact.parse(gpa, target.bytes) catch |err| return mapTargetParseError(err);
    }
    defer target.deinit(gpa);
    if (target.manifest.kind != .manifest) return error.TargetNotManifest;

    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;
            item.manifest = artifact.parse(gpa, item.bytes) catch |err| return mapBaselineParseError(err);
        }
        if (item.manifest.kind != .manifest) {
            item.deinit(gpa);
            return error.BaselineNotManifest;
        }
        baseline = item;
        baseline_context.expected_hash = b.hash;
        baseline_context.manifest = &baseline.?.manifest;
    }

    const target_files = manifestFiles(&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;
}

fn manifestFiles(manifest: *const artifact.Manifest) artifact.ManifestFiles {
    return .{
        .baseline_hash = if (manifest.b) |b| b.hash else null,
        .files = manifest.f,
    };
}

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 manifestFiles(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);
    if (current == null) {
        if (current_managed.iterate().len == 0) return Plan{};
        return error.WorktreeNotClean;
    }

    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;
    }
    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,
    };
    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;
    if (stat.size != expected.?.len) return false;

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

fn deriveTargetBranch(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError![]u8 {
    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.
    const main_branch = if (config) |owned| owned.config.default_branch else "trunk";
    const branch = assembly.state.branchOf(assembly.target_rid, main_branch);
    return gpa.dupe(u8, branch) catch error.OutOfMemory;
}

/// State-record classification is intentionally conservative: any successful
/// classified manifest is crosslinked, any successful classified control
/// artifact ingests only tag effects, and all other successful classifications
/// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque:
/// the log frame format has no structural-kind bit to distinguish a blob from
/// malformed cards. Integrity failures and non-syntax parse/classification
/// failures remain fatal.
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.?);

        var parsed = artifact.parse(gpa, bytes.?) catch |err| switch (err) {
            error.SyntaxError => continue,
            error.OutOfMemory => return error.OutOfMemory,
            else => return error.StateRecordInvalid,
        };
        defer parsed.deinit();

        switch (parsed.kind) {
            .manifest => {
                if (!artifact.verify(name, bytes.?)) return error.Corrupt;
                const record = try manifestRecord(gpa, name, &parsed);
                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);
                defer gpa.free(record.tags);
                _ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory;
            },
            else => {},
        }
    }

    return .{
        .gpa = gpa,
        .mem = mem,
        .state = state,
        .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 };
}

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 error.Corrupt;
        const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
        if (bytes == null) return error.Corrupt;
        defer gpa.free(bytes.?);
        if (!artifact.verify(entry.name, bytes.?)) return error.Corrupt;
        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 => 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 => 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,
) ApplyError!void {
    _ = previous;
    _ = old_managed;
    try removePlanFiles(io, worktree, old, true);
    try materializePlan(gpa, io, next, worktree, target, created_directories);
    if (shouldInjectTestFault(.after_materialize)) return error.Storage;

    desired_managed.write(io, worktree) catch |err| return mapManagedError(err);
    checkout.write(io, worktree, next_checkout) catch |err| return mapCheckoutError(err);
}

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);
        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);
    var file = parent.dir.createFile(io, parent.name, .{
        .read = false,
        .exclusive = exclusive,
        .truncate = true,
        .permissions = switch (mode) {
            .regular => .default_file,
            .executable => .executable_file,
            .symlink => return error.SymlinkUnsupported,
        },
        .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;
}

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

fn validateWorktreePath(path: []const u8) ApplyError!void {
    if (path.len == 0 or std.fs.path.isAbsolute(path)) return error.InvalidPath;
    if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.InvalidPath;

    var it = std.mem.splitScalar(u8, path, '/');
    const first = it.next() orelse return error.InvalidPath;
    if (first.len == 0 or isReservedRootName(first)) return error.ReservedPath;
    while (it.next()) |component| {
        if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath;
    }
    if (std.mem.eql(u8, first, ".") or std.mem.eql(u8, first, "..")) return error.InvalidPath;
}

fn isReservedRootName(name: []const u8) bool {
    return asciiEqlIgnoreCase(name, checkout.filename) or
        asciiEqlIgnoreCase(name, managed.filename) or
        asciiStartsWithIgnoreCase(name, ".zit-update-txn-");
}

fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool {
    if (a.len != b.len) return false;
    for (a, b) |left, right| {
        if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false;
    }
    return true;
}

fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool {
    if (value.len < prefix.len) return false;
    return asciiEqlIgnoreCase(value[0..prefix.len], prefix);
}

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);
}
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 iterator = worktree.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" });
    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" });
    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("zit_storage");
    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));

    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("zit_storage");
    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);

    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("zit_storage");
    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));

    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 retains the committed checkout after post-checkout cleanup failure" {
    const t = std.testing;
    const test_storage = @import("zit_storage");
    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));

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

test "update bootstrap clean state leaves checkout metadata absent" {
    const t = std.testing;
    const test_storage = @import("zit_storage");
    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 path admission before staging" {
    try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZiT-Managed"));
    try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZIT-UPDATE-TXN-deadbeef/file"));
    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("zit_storage");
    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("zit_storage");
    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));
}

test "update state assembly retains the stable MemStore backing RepoState" {
    const t = std.testing;
    const test_storage = @import("zit_storage");
    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).?);
}