Skip to content
zit Artifact [2e213cbe02]
Login

Artifact [2e213cbe02]

Artifact 2e213cbe02118e37b11554620b285deae5dbf5b4d8009ae6dc229b84375b7591:


//! Single-level undo/redo journal for the working-file mutators (`zit update`,
//! `zit revert`). Contract: contracts/undo.md (B-undo-1..21). No RFC touches the
//! working tree — this is a checkout-layer safety net and a settled DX decision,
//! so the behavior contract, not an RFC, is the oracle.
//!
//! The on-disk journal `.zit-undo` at the checkout root holds at most ONE undo
//! image and at most ONE redo image (fossil-parity single level, NOT a stack).
//! An *image* is the pre-mutation bytes+mode of every path a sheltered verb will
//! write or delete, plus the pre-mutation `.zit-checkout` and `.zit-managed`
//! records — enough to make the restored tree indistinguishable from before the
//! mutation (B-undo-1). Callers never see the wire format or the slot
//! bookkeeping: a mutator calls `record` once before it moves its first byte
//! (B-undo-3/14); `undo`/`redo` swap the two slots and materialize.
//!
//! This module owns NO second file-writing path (B-undo-12): working files
//! restore through `update.restoreFile`/`update.removeFile`, `.zit-checkout`
//! through `checkout.write`; only the whole-file control records `.zit-undo`
//! and `.zit-managed` are written here, each through the same atomic
//! write-temp + fsync + rename sequence `checkout.write` uses.
//!
//! Effects flow only through an injected `std.Io` + `std.Io.Dir` (design-seams
//! rule — no ambient filesystem access); tests bind `std.testing.io` + a tmpDir.

const std = @import("std");
const artifact = @import("artifact.zig");
const checkout = @import("checkout.zig");
const managed = @import("managed.zig");
const update = @import("update.zig");

/// The journal file, resolved relative to the injected checkout root. Joins the
/// never-tracked control set and the extras exclusion (B-undo-13).
pub const filename: []const u8 = ".zit-undo";

/// An image never enumerates more paths than a full managed set (B-undo bound).
pub const max_files: usize = managed.max_entries;
/// Per-file worktree-content cap carried in an image; the managed-set aggregate
/// bound doubles as the single-file ceiling.
pub const max_blob: usize = managed.max_file_bytes;

const magic = "ZITUNDO\x00"; // 8 bytes
// Bumped to 2 when the checkout slot gained a fourth `special_op` string (the
// pending cherry-pick/backout op): a v1 journal lacks that field, so the reader
// rejects it as Corrupt rather than misparsing the following bytes. Undo
// journals are transient working-tree state, so an unreadable stale journal is
// harmless.
const fmt_version: u8 = 2;

/// Closed error set at the module boundary. `Empty` is the one exit-1 signal
/// for a missing required slot (B-undo-9/10 make emptiness an operation
/// failure, not empty-output success); `Corrupt` is a damaged journal caught at
/// parse before any write (B-undo-11); `Storage` is any device fault.
pub const JournalError = error{ Storage, Corrupt, OutOfMemory, Empty };

// ---------------------------------------------------------------------------
// In-memory image (arena-backed for the lifetime of one call).
// ---------------------------------------------------------------------------

const FileEntry = struct {
    /// Repo-relative worktree path (arena-owned).
    path: []const u8,
    /// Whether the path held a regular file at snapshot time. Absent = the
    /// mutation created it (undo deletes it), so absence is an image entry, not
    /// an error (errors defined out of existence).
    present: bool,
    mode: artifact.FileMode,
    /// The file's bytes when `present`; empty otherwise (arena-owned).
    blob: []const u8,
};

const Image = struct {
    /// The `.zit-checkout` record at snapshot time, or null when absent (a fresh
    /// repo's first checkout). Strings arena-owned.
    checkout: ?checkout.CheckoutState,
    /// The raw `.zit-managed` bytes at snapshot time, or null when absent.
    /// Whole-file so restoration is byte-exact regardless of any pending
    /// rename record the structured set would drop. Arena-owned.
    managed: ?[]const u8,
    files: []const FileEntry,
};

const Slots = struct {
    undo: ?Image,
    redo: ?Image,
};

// ---------------------------------------------------------------------------
// Public API.
// ---------------------------------------------------------------------------

/// Record the undo image for a mutation about to touch `paths`, replacing any
/// existing journal (which clears the stale redo slot — B-undo-7). Called ONCE
/// per sheltered verb, BEFORE the first working-file byte moves: the write is
/// fsync'd before returning, so a crash after this leaves a tree `zit undo` can
/// recover and a crash before it leaves the untouched pre-mutation tree
/// (B-undo-3, the B-commit-15 discipline on the checkout layer). `paths` should
/// be deduplicated by the caller; duplicates are harmless (idempotent restore).
pub fn record(
    gpa: std.mem.Allocator,
    io: std.Io,
    root: std.Io.Dir,
    paths: []const []const u8,
) JournalError!void {
    std.debug.assert(paths.len <= max_files);
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const a = arena.allocator();

    const image = try captureImage(a, io, root, paths);
    // Undo slot holds the pre-image; redo slot is cleared (a new mutation always
    // invalidates redo).
    try writeJournal(gpa, io, root, .{ .undo = image, .redo = null });
}

/// undo: materialize the undo image onto the tree, moving the pre-undo
/// (post-mutation) state into the redo slot as it goes, and consume the undo
/// slot (B-undo-4). `Empty` when no undo image is present (B-undo-6/9). The
/// whole journal is parsed and validated BEFORE the first working file is
/// touched, so a corrupt journal aborts with the tree byte-identical
/// (B-undo-11).
pub fn undo(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!void {
    return swap(gpa, io, root, .undo_dir);
}

/// redo: the exact inverse of `undo` — materialize the redo image, repopulate
/// the undo slot, consume the redo slot (B-undo-5). `Empty` when no redo image
/// is available (B-undo-10).
pub fn redo(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!void {
    return swap(gpa, io, root, .redo_dir);
}

/// Drop the journal after a sheltered mutation cleanly rolls back, so a failed
/// update leaves no spurious undo level (keeps B-undo-9 honest). Best-effort:
/// an absent journal is success, and a delete fault is swallowed because the
/// mutation it guarded did not happen.
pub fn clear(io: std.Io, root: std.Io.Dir) void {
    root.deleteFile(io, filename) catch {};
}

const Direction = enum { undo_dir, redo_dir };

/// The shared swap engine for `undo`/`redo`. `dir` selects which slot restores
/// onto the tree and which receives the captured current state. Ordering
/// (B-undo-4, durability): parse+validate the whole journal, capture the
/// current state of the affected paths into the opposite slot IN MEMORY, write
/// and fsync the swapped journal (the opposite image is durable before the
/// post-mutation bytes are overwritten), then materialize the restore.
fn swap(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, dir: Direction) JournalError!void {
    const raw = (try readJournalFile(gpa, io, root)) orelse return error.Empty;
    defer gpa.free(raw);

    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const a = arena.allocator();

    const slots = try parseJournal(a, raw); // Corrupt caught here, before any write
    const source = (if (dir == .undo_dir) slots.undo else slots.redo) orelse return error.Empty;

    // Capture the current (post-mutation) state of exactly the paths the source
    // image restores, so the discarded state round-trips.
    const path_list = try imagePaths(a, source);
    const captured = try captureImage(a, io, root, path_list);

    const next: Slots = if (dir == .undo_dir)
        .{ .undo = null, .redo = captured }
    else
        .{ .undo = captured, .redo = null };
    try writeJournal(gpa, io, root, next);

    try materialize(io, root, source);
}

// ---------------------------------------------------------------------------
// Snapshot (capture the current state of a path set + control records).
// ---------------------------------------------------------------------------

/// Snapshot the current bytes+mode of every path in `paths` plus the current
/// `.zit-checkout` and `.zit-managed` records into an arena-backed image. A
/// path that is absent (or not a regular file) is recorded `present = false`.
fn captureImage(
    a: std.mem.Allocator,
    io: std.Io,
    root: std.Io.Dir,
    paths: []const []const u8,
) JournalError!Image {
    std.debug.assert(paths.len <= max_files);
    var files = try a.alloc(FileEntry, paths.len);
    for (paths, 0..) |path, i| {
        std.debug.assert(path.len <= managed.max_path_len);
        files[i] = try captureFile(a, io, root, path);
    }
    return .{
        .checkout = try captureCheckout(a, io, root),
        .managed = try captureManaged(a, io, root),
        .files = files,
    };
}

fn captureFile(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, path: []const u8) JournalError!FileEntry {
    const st = root.statFile(io, path, .{ .follow_symlinks = false }) catch |e| switch (e) {
        error.FileNotFound => return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" },
        else => return error.Storage,
    };
    if (st.kind != .file) {
        // A directory/symlink at this path is not a regular-file pre-image; the
        // mutation replaces it, so undo treats it as absent (see the module note
        // on transitions).
        return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" };
    }
    const mode: artifact.FileMode = if (isExecutable(st)) .executable else .regular;
    const blob = root.readFileAlloc(io, path, a, .limited(max_blob)) catch |e| switch (e) {
        error.FileNotFound => return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" },
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage, // includes a file larger than max_blob
    };
    return .{ .path = try a.dupe(u8, path), .present = true, .mode = mode, .blob = blob };
}

fn captureCheckout(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?checkout.CheckoutState {
    var buf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = checkout.read(io, root, &buf) catch return error.Storage;
    const state = ck orelse return null;
    return .{
        .version = try a.dupe(u8, state.version),
        .branch = try a.dupe(u8, state.branch),
        .merge_parent = try a.dupe(u8, state.merge_parent),
        .special_op = try a.dupe(u8, state.special_op),
    };
}

fn captureManaged(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]const u8 {
    return root.readFileAlloc(io, managed.filename, a, .limited(managed.max_file_bytes)) catch |e| switch (e) {
        error.FileNotFound => null,
        error.OutOfMemory => error.OutOfMemory,
        else => error.Storage,
    };
}

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

fn imagePaths(a: std.mem.Allocator, image: Image) JournalError![]const []const u8 {
    var list = try a.alloc([]const u8, image.files.len);
    for (image.files, 0..) |fe, i| list[i] = fe.path;
    return list;
}

// ---------------------------------------------------------------------------
// Materialize (restore an image onto the working tree).
// ---------------------------------------------------------------------------

/// Restore `image` onto the tree: working files first (each an atomic
/// update-layer write or targeted delete), then `.zit-managed`, then
/// `.zit-checkout` last as the commit point — the same last-write ordering
/// `applyCheckin` uses. Restoration reuses `update`'s materialization and
/// `checkout.write`; undo owns no parallel path (B-undo-12).
fn materialize(io: std.Io, root: std.Io.Dir, image: Image) JournalError!void {
    std.debug.assert(image.files.len <= max_files);
    for (image.files) |fe| {
        if (fe.present) {
            std.debug.assert(fe.blob.len <= max_blob);
            update.restoreFile(io, root, fe.path, fe.blob, fe.mode) catch return error.Storage;
        } else {
            update.removeFile(io, root, fe.path) catch return error.Storage;
        }
    }
    if (image.managed) |bytes| {
        try writeRawAtomic(io, root, managed.filename, bytes);
    } else {
        root.deleteFile(io, managed.filename) catch |e| switch (e) {
            error.FileNotFound => {},
            else => return error.Storage,
        };
    }
    if (image.checkout) |ck| {
        checkout.write(io, root, ck) catch return error.Storage;
    } else {
        root.deleteFile(io, checkout.filename) catch |e| switch (e) {
            error.FileNotFound => {},
            else => return error.Storage,
        };
    }
}

/// Atomic whole-file write (write-temp + fsync + rename-replace), the same
/// durable sequence `checkout.write`/`ManagedSet.write` use. The one place a
/// control record's raw bytes are restored byte-for-byte.
fn writeRawAtomic(io: std.Io, root: std.Io.Dir, name: []const u8, bytes: []const u8) JournalError!void {
    var af = root.createFileAtomic(io, name, .{ .replace = true }) catch return error.Storage;
    defer af.deinit(io);
    af.file.writePositionalAll(io, bytes, 0) catch return error.Storage;
    af.file.sync(io) catch return error.Storage; // fsync before the rename
    af.replace(io) catch return error.Storage;
}

// ---------------------------------------------------------------------------
// Wire format. `magic(8) | fmt(1) | slot(undo) | slot(redo) | crc32 le(4)`,
// crc over all preceding bytes. A slot is `present u8 [image]`. Every length is
// bounds-checked against the remaining input and its cap before use;
// consumption must be exact; the crc must match. Any violation is
// `error.Corrupt` (B-undo-11). Read via `readJournalFile`, written via
// `writeJournal`, both atomic + fsync'd.
// ---------------------------------------------------------------------------

fn writeJournal(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, slots: Slots) JournalError!void {
    const bytes = try encodeJournal(gpa, slots);
    defer gpa.free(bytes);
    try writeRawAtomic(io, root, filename, bytes);
    // Negative space: the just-written slots reflect the intended swap.
    std.debug.assert((slots.undo == null) or (slots.redo == null));
}

fn readJournalFile(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]u8 {
    return root.readFileAlloc(io, filename, gpa, .limited(journal_read_cap)) catch |e| switch (e) {
        error.FileNotFound => null,
        error.OutOfMemory => error.OutOfMemory,
        else => error.Storage,
    };
}

/// Upper bound on a well-formed journal: two images, each at most a full
/// managed set of max-size blobs, plus both control records and framing. A
/// larger file is rejected before allocation grows unbounded.
const journal_read_cap: usize = 2 * (2 * managed.max_file_bytes + 4096) + 64;

fn encodeJournal(gpa: std.mem.Allocator, slots: Slots) JournalError![]u8 {
    var buf: std.ArrayList(u8) = .empty;
    errdefer buf.deinit(gpa);
    buf.appendSlice(gpa, magic) catch return error.OutOfMemory;
    buf.append(gpa, fmt_version) catch return error.OutOfMemory;
    try encodeSlot(gpa, &buf, slots.undo);
    try encodeSlot(gpa, &buf, slots.redo);
    var crc: [4]u8 = undefined;
    std.mem.writeInt(u32, &crc, std.hash.Crc32.hash(buf.items), .little);
    buf.appendSlice(gpa, &crc) catch return error.OutOfMemory;
    return buf.toOwnedSlice(gpa) catch return error.OutOfMemory;
}

fn encodeSlot(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), image: ?Image) JournalError!void {
    const img = image orelse {
        buf.append(gpa, 0) catch return error.OutOfMemory;
        return;
    };
    buf.append(gpa, 1) catch return error.OutOfMemory;
    // checkout
    if (img.checkout) |ck| {
        buf.append(gpa, 1) catch return error.OutOfMemory;
        try encodeStr16(gpa, buf, ck.version);
        try encodeStr16(gpa, buf, ck.branch);
        try encodeStr16(gpa, buf, ck.merge_parent);
        try encodeStr16(gpa, buf, ck.special_op);
    } else {
        buf.append(gpa, 0) catch return error.OutOfMemory;
    }
    // managed (raw)
    if (img.managed) |m| {
        buf.append(gpa, 1) catch return error.OutOfMemory;
        try encodeU32(gpa, buf, @intCast(m.len));
        buf.appendSlice(gpa, m) catch return error.OutOfMemory;
    } else {
        buf.append(gpa, 0) catch return error.OutOfMemory;
    }
    // files
    try encodeU32(gpa, buf, @intCast(img.files.len));
    for (img.files) |fe| {
        try encodeStr16(gpa, buf, fe.path);
        buf.append(gpa, @intFromBool(fe.present)) catch return error.OutOfMemory;
        buf.append(gpa, modeByte(fe.mode)) catch return error.OutOfMemory;
        if (fe.present) {
            try encodeU64(gpa, buf, @intCast(fe.blob.len));
            buf.appendSlice(gpa, fe.blob) catch return error.OutOfMemory;
        }
    }
}

fn encodeStr16(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) JournalError!void {
    var len: [2]u8 = undefined;
    std.mem.writeInt(u16, &len, @intCast(s.len), .little);
    buf.appendSlice(gpa, &len) catch return error.OutOfMemory;
    buf.appendSlice(gpa, s) catch return error.OutOfMemory;
}

fn encodeU32(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), v: u32) JournalError!void {
    var b: [4]u8 = undefined;
    std.mem.writeInt(u32, &b, v, .little);
    buf.appendSlice(gpa, &b) catch return error.OutOfMemory;
}

fn encodeU64(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), v: u64) JournalError!void {
    var b: [8]u8 = undefined;
    std.mem.writeInt(u64, &b, v, .little);
    buf.appendSlice(gpa, &b) catch return error.OutOfMemory;
}

fn modeByte(mode: artifact.FileMode) u8 {
    return switch (mode) {
        .regular => 0,
        .executable => 1,
        .symlink => 2, // never emitted; parsed back as Corrupt (worktree files are regular)
    };
}

/// A forward cursor over the journal bytes. Every read bounds-checks against the
/// remaining input; an over-read is `error.Corrupt`, never a panic.
const Cursor = struct {
    bytes: []const u8,
    pos: usize = 0,

    fn u8v(self: *Cursor) JournalError!u8 {
        if (self.pos + 1 > self.bytes.len) return error.Corrupt;
        const v = self.bytes[self.pos];
        self.pos += 1;
        return v;
    }
    fn u16v(self: *Cursor) JournalError!u16 {
        if (self.pos + 2 > self.bytes.len) return error.Corrupt;
        const v = std.mem.readInt(u16, self.bytes[self.pos..][0..2], .little);
        self.pos += 2;
        return v;
    }
    fn u32v(self: *Cursor) JournalError!u32 {
        if (self.pos + 4 > self.bytes.len) return error.Corrupt;
        const v = std.mem.readInt(u32, self.bytes[self.pos..][0..4], .little);
        self.pos += 4;
        return v;
    }
    fn u64v(self: *Cursor) JournalError!u64 {
        if (self.pos + 8 > self.bytes.len) return error.Corrupt;
        const v = std.mem.readInt(u64, self.bytes[self.pos..][0..8], .little);
        self.pos += 8;
        return v;
    }
    fn take(self: *Cursor, n: usize) JournalError![]const u8 {
        if (self.pos + n > self.bytes.len) return error.Corrupt;
        const s = self.bytes[self.pos..][0..n];
        self.pos += n;
        return s;
    }
};

fn parseJournal(a: std.mem.Allocator, raw: []const u8) JournalError!Slots {
    if (raw.len < magic.len + 1 + 4) return error.Corrupt;
    if (!std.mem.eql(u8, raw[0..magic.len], magic)) return error.Corrupt;

    // crc covers every byte before the trailing 4-byte crc field.
    const body = raw[0 .. raw.len - 4];
    const stored = std.mem.readInt(u32, raw[raw.len - 4 ..][0..4], .little);
    if (std.hash.Crc32.hash(body) != stored) return error.Corrupt;

    var cur = Cursor{ .bytes = body, .pos = magic.len };
    if (try cur.u8v() != fmt_version) return error.Corrupt;
    const undo_img = try parseSlot(a, &cur);
    const redo_img = try parseSlot(a, &cur);
    if (cur.pos != body.len) return error.Corrupt; // exact consumption, no trailing bytes
    return .{ .undo = undo_img, .redo = redo_img };
}

fn parseSlot(a: std.mem.Allocator, cur: *Cursor) JournalError!?Image {
    switch (try cur.u8v()) {
        0 => return null,
        1 => {},
        else => return error.Corrupt,
    }
    const ck: ?checkout.CheckoutState = switch (try cur.u8v()) {
        0 => null,
        1 => blk: {
            const version = try parseStr16(a, cur);
            const branch = try parseStr16(a, cur);
            const merge_parent = try parseStr16(a, cur);
            const special_op = try parseStr16(a, cur);
            break :blk .{ .version = version, .branch = branch, .merge_parent = merge_parent, .special_op = special_op };
        },
        else => return error.Corrupt,
    };
    const m: ?[]const u8 = switch (try cur.u8v()) {
        0 => null,
        1 => blk: {
            const len = try cur.u32v();
            if (len > managed.max_file_bytes) return error.Corrupt;
            break :blk try a.dupe(u8, try cur.take(len));
        },
        else => return error.Corrupt,
    };
    const count = try cur.u32v();
    if (count > max_files) return error.Corrupt;
    var files = try a.alloc(FileEntry, count);
    for (0..count) |i| files[i] = try parseFile(a, cur);
    return .{ .checkout = ck, .managed = m, .files = files };
}

fn parseFile(a: std.mem.Allocator, cur: *Cursor) JournalError!FileEntry {
    const path = try parseStr16Path(a, cur);
    const present = switch (try cur.u8v()) {
        0 => false,
        1 => true,
        else => return error.Corrupt,
    };
    const mode: artifact.FileMode = switch (try cur.u8v()) {
        0 => .regular,
        1 => .executable,
        else => return error.Corrupt, // symlink/other never a valid worktree pre-image
    };
    if (!present) return .{ .path = path, .present = false, .mode = mode, .blob = "" };
    const len = try cur.u64v();
    if (len > max_blob) return error.Corrupt;
    const blob = try a.dupe(u8, try cur.take(@intCast(len)));
    return .{ .path = path, .present = true, .mode = mode, .blob = blob };
}

fn parseStr16(a: std.mem.Allocator, cur: *Cursor) JournalError![]const u8 {
    const len = try cur.u16v();
    if (len > checkout.max_ck_str) return error.Corrupt;
    return a.dupe(u8, try cur.take(len)) catch error.OutOfMemory;
}

fn parseStr16Path(a: std.mem.Allocator, cur: *Cursor) JournalError![]const u8 {
    const len = try cur.u16v();
    if (len == 0 or len > managed.max_path_len) return error.Corrupt;
    return a.dupe(u8, try cur.take(len)) catch error.OutOfMemory;
}

// ---------------------------------------------------------------------------
// Tests (contracts/undo.md). Expected values are the exact bytes each test
// stages pre-mutation — the contract defines restoration as returning those
// bytes; no fossil is ever run.
// ---------------------------------------------------------------------------

const testing = std.testing;

fn writeWorktreeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, bytes: []const u8) !void {
    var f = try dir.createFile(io, name, .{ .truncate = true });
    defer f.close(io);
    try f.writePositionalAll(io, bytes, 0);
}

fn readWorktreeFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8) ![]u8 {
    return dir.readFileAlloc(io, name, gpa, .limited(1 << 20));
}

test "undo journal: record then parse round-trips checkout, managed, and file entries" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    try checkout.write(io, tmp.dir, .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("a.txt");
    try set.write(io, tmp.dir);
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");

    try record(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });

    const raw = (try readJournalFile(gpa, io, tmp.dir)).?;
    defer gpa.free(raw);
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const slots = try parseJournal(arena.allocator(), raw);
    try testing.expect(slots.undo != null);
    try testing.expect(slots.redo == null); // record clears redo (B-undo-7)
    const img = slots.undo.?;
    try testing.expectEqualStrings("a" ** 64, img.checkout.?.version);
    try testing.expectEqualStrings("trunk", img.checkout.?.branch);
    try testing.expect(img.managed != null);
    try testing.expectEqual(@as(usize, 2), img.files.len);
    try testing.expectEqualStrings("a.txt", img.files[0].path);
    try testing.expect(img.files[0].present);
    try testing.expectEqualStrings("PRE\n", img.files[0].blob);
    try testing.expectEqualStrings("b.txt", img.files[1].path);
    try testing.expect(!img.files[1].present); // absent path recorded, not an error
}

test "undo journal B-undo-3: record writes+fsyncs the pre-image before any mutation, and undo recovers it" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    // Pre-mutation tree.
    try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("a.txt");
    try set.write(io, tmp.dir);
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");

    // Journal is written by record; the working file is still the pre-image (the
    // ordering guarantee: the journal is durable before the first byte moves).
    try record(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" });
    {
        const still = try readWorktreeFile(gpa, io, tmp.dir, "a.txt");
        defer gpa.free(still);
        try testing.expectEqualStrings("PRE\n", still);
    }

    // Now simulate the mutation: overwrite a.txt, create b.txt, advance the
    // control records — exactly what `applyCheckin` does after `record` returns.
    try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n");
    try writeWorktreeFile(io, tmp.dir, "b.txt", "NEW\n");
    try checkout.write(io, tmp.dir, .{ .version = "2" ** 64, .branch = "feature", .merge_parent = "", .special_op = "" });
    _ = try set.insert("b.txt");
    try set.write(io, tmp.dir);

    // undo restores the journaled pre-image byte-for-byte.
    try undo(gpa, io, tmp.dir);
    {
        const restored = try readWorktreeFile(gpa, io, tmp.dir, "a.txt");
        defer gpa.free(restored);
        try testing.expectEqualStrings("PRE\n", restored);
    }
    try testing.expect(!fileExists(io, tmp.dir, "b.txt")); // created file removed
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?;
    try testing.expectEqualStrings("1" ** 64, ck.version);
    try testing.expectEqualStrings("trunk", ck.branch);
}

test "undo journal B-undo-4/5: undo then redo round-trips the post-mutation state" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("a.txt");
    try set.write(io, tmp.dir);
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");

    try record(gpa, io, tmp.dir, &.{"a.txt"});
    try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n");
    try checkout.write(io, tmp.dir, .{ .version = "2" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });

    try undo(gpa, io, tmp.dir); // -> PRE
    {
        const v = try readWorktreeFile(gpa, io, tmp.dir, "a.txt");
        defer gpa.free(v);
        try testing.expectEqualStrings("PRE\n", v);
    }
    try redo(gpa, io, tmp.dir); // -> POST (exact inverse)
    {
        const v = try readWorktreeFile(gpa, io, tmp.dir, "a.txt");
        defer gpa.free(v);
        try testing.expectEqualStrings("POST\n", v);
    }
    var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined;
    const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?;
    try testing.expectEqualStrings("2" ** 64, ck.version);
}

test "undo journal B-undo-6: a second consecutive undo finds no image (Empty)" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("a.txt");
    try set.write(io, tmp.dir);
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");
    try record(gpa, io, tmp.dir, &.{"a.txt"});
    try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n");

    try undo(gpa, io, tmp.dir);
    try testing.expectError(error.Empty, undo(gpa, io, tmp.dir));
}

test "undo journal B-undo-9/10: a fresh repo has no undo and no redo image" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try testing.expectError(error.Empty, undo(gpa, io, tmp.dir));
    try testing.expectError(error.Empty, redo(gpa, io, tmp.dir));
}

test "undo journal B-undo-11: a corrupt journal is error.Corrupt and mutates nothing" {
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("a.txt");
    try set.write(io, tmp.dir);
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");
    try record(gpa, io, tmp.dir, &.{"a.txt"});
    try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n");

    // Corrupt the journal (flip a byte inside the payload).
    {
        const raw = (try readJournalFile(gpa, io, tmp.dir)).?;
        defer gpa.free(raw);
        raw[magic.len + 3] ^= 0xff;
        try writeRawAtomic(io, tmp.dir, filename, raw);
    }
    try testing.expectError(error.Corrupt, undo(gpa, io, tmp.dir));
    const unchanged = try readWorktreeFile(gpa, io, tmp.dir, "a.txt");
    defer gpa.free(unchanged);
    try testing.expectEqualStrings("POST\n", unchanged); // no partial restore
}

test "undo journal B-undo-12: undo restores an executable bit through the update layer" {
    if (comptime !std.Io.File.Permissions.has_executable_bit) return error.SkipZigTest;
    const gpa = testing.allocator;
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "" });
    var set = managed.ManagedSet.init(gpa);
    defer set.deinit();
    _ = try set.insert("s.sh");
    try set.write(io, tmp.dir);
    // Stage an executable pre-image via the update layer (the same writer undo reuses).
    try update.restoreFile(io, tmp.dir, "s.sh", "#!/bin/sh\n", .executable);

    try record(gpa, io, tmp.dir, &.{"s.sh"});
    try update.restoreFile(io, tmp.dir, "s.sh", "changed\n", .regular);

    try undo(gpa, io, tmp.dir);
    const st = try tmp.dir.statFile(io, "s.sh", .{ .follow_symlinks = false });
    try testing.expect(isExecutable(st)); // exec bit restored
    const bytes = try readWorktreeFile(gpa, io, tmp.dir, "s.sh");
    defer gpa.free(bytes);
    try testing.expectEqualStrings("#!/bin/sh\n", bytes);
}

test "undo journal: encodeJournal/parseJournal round-trips both slots" {
    const gpa = testing.allocator;
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const a = arena.allocator();

    const undo_img = Image{
        .checkout = .{ .version = "v", .branch = "b", .merge_parent = "m", .special_op = "+" ++ "a" ** 64 },
        .managed = "MANAGED",
        .files = &.{
            .{ .path = "x", .present = true, .mode = .executable, .blob = "hi" },
            .{ .path = "y/z", .present = false, .mode = .regular, .blob = "" },
        },
    };
    const bytes = try encodeJournal(gpa, .{ .undo = undo_img, .redo = null });
    defer gpa.free(bytes);
    const slots = try parseJournal(a, bytes);
    try testing.expect(slots.redo == null);
    const got = slots.undo.?;
    try testing.expectEqualStrings("v", got.checkout.?.version);
    try testing.expectEqualStrings("m", got.checkout.?.merge_parent); // third field round-trips
    try testing.expectEqualStrings("+" ++ "a" ** 64, got.checkout.?.special_op); // fourth field round-trips
    try testing.expectEqualStrings("MANAGED", got.managed.?);
    try testing.expectEqual(@as(usize, 2), got.files.len);
    try testing.expect(got.files[0].mode == .executable);
    try testing.expectEqualStrings("hi", got.files[0].blob);
    try testing.expect(!got.files[1].present);
}

test "undo journal B-undo-11: corruption cases are error.Corrupt, never a crash" {
    const gpa = testing.allocator;
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const a = arena.allocator();

    const good = try encodeJournal(gpa, .{
        .undo = .{ .checkout = null, .managed = null, .files = &.{} },
        .redo = null,
    });
    defer gpa.free(good);
    // Sanity: the well-formed journal parses.
    _ = try parseJournal(a, good);

    // Bad magic.
    {
        const bad = try gpa.dupe(u8, good);
        defer gpa.free(bad);
        bad[0] = 'X';
        try testing.expectError(error.Corrupt, parseJournal(a, bad));
    }
    // Bad crc.
    {
        const bad = try gpa.dupe(u8, good);
        defer gpa.free(bad);
        bad[bad.len - 1] ^= 0xff;
        try testing.expectError(error.Corrupt, parseJournal(a, bad));
    }
    // Truncated (drop the crc).
    try testing.expectError(error.Corrupt, parseJournal(a, good[0 .. good.len - 1]));
    // Trailing byte (crc no longer over the true body).
    {
        const bad = try gpa.alloc(u8, good.len + 1);
        defer gpa.free(bad);
        @memcpy(bad[0..good.len], good);
        bad[good.len] = 0;
        try testing.expectError(error.Corrupt, parseJournal(a, bad));
    }
}

test "undo journal B-undo bound: a file_count over max_files is error.Corrupt" {
    const gpa = testing.allocator;
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    const a = arena.allocator();

    // Hand-build: magic | fmt | undo present | ck absent | managed absent |
    // file_count = max_files+1 | redo absent | crc.
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(gpa);
    try buf.appendSlice(gpa, magic);
    try buf.append(gpa, fmt_version);
    try buf.append(gpa, 1); // undo present
    try buf.append(gpa, 0); // ck absent
    try buf.append(gpa, 0); // managed absent
    try encodeU32(gpa, &buf, @intCast(max_files + 1)); // over the bound
    try buf.append(gpa, 0); // redo absent
    var crc: [4]u8 = undefined;
    std.mem.writeInt(u32, &crc, std.hash.Crc32.hash(buf.items), .little);
    try buf.appendSlice(gpa, &crc);
    try testing.expectError(error.Corrupt, parseJournal(a, buf.items));
}

// Seeded deterministic fuzz: parseJournal on adversarial bytes yields a value
// or error.Corrupt, never a crash; and parseJournal(encodeJournal(x)) recovers
// x for any bounded slot pair.
test "undo journal fuzz: parseJournal never crashes and encode round-trips" {
    const gpa = testing.allocator;
    var prng = std.Random.DefaultPrng.init(0x5EED_11D0);
    const random = prng.random();

    var iter: usize = 0;
    while (iter < 500) : (iter += 1) {
        var arena = std.heap.ArenaAllocator.init(gpa);
        defer arena.deinit();
        const a = arena.allocator();

        // Round-trip a randomly shaped image.
        const undo_img = try randomImage(a, random);
        const bytes = try encodeJournal(gpa, .{ .undo = undo_img, .redo = null });
        defer gpa.free(bytes);
        const slots = try parseJournal(a, bytes);
        try testing.expect(slots.undo != null);
        try testing.expectEqual(undo_img.files.len, slots.undo.?.files.len);

        // Garbage: any random byte string is a value or Corrupt, never a crash.
        var garbage: [96]u8 = undefined;
        const gn = random.uintAtMost(usize, garbage.len);
        random.bytes(garbage[0..gn]);
        if (parseJournal(a, garbage[0..gn])) |_| {} else |err| switch (err) {
            error.Corrupt, error.OutOfMemory => {},
            error.Storage, error.Empty => unreachable,
        }

        // Flip one byte of a valid journal: parse yields a value or Corrupt.
        if (bytes.len > 0) {
            const mutant = try a.dupe(u8, bytes);
            mutant[random.uintLessThan(usize, mutant.len)] ^= @as(u8, 1) << random.int(u3);
            if (parseJournal(a, mutant)) |_| {} else |err| switch (err) {
                error.Corrupt, error.OutOfMemory => {},
                error.Storage, error.Empty => unreachable,
            }
        }
    }
}

fn randomImage(a: std.mem.Allocator, random: std.Random) !Image {
    const n = random.uintAtMost(usize, 4);
    var files = try a.alloc(FileEntry, n);
    for (0..n) |i| {
        const plen = random.uintLessThan(usize, 8) + 1;
        const path = try a.alloc(u8, plen);
        random.bytes(path);
        const present = random.boolean();
        var blob: []const u8 = "";
        if (present) {
            const blen = random.uintAtMost(usize, 16);
            const b = try a.alloc(u8, blen);
            random.bytes(b);
            blob = b;
        }
        files[i] = .{
            .path = path,
            .present = present,
            .mode = if (random.boolean()) .executable else .regular,
            .blob = blob,
        };
    }
    return .{
        .checkout = if (random.boolean()) .{ .version = "ver", .branch = "br", .merge_parent = "mp", .special_op = "" } else null,
        .managed = if (random.boolean()) "M" else null,
        .files = files,
    };
}

fn fileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
    var f = dir.openFile(io, name, .{}) catch return false;
    f.close(io);
    return true;
}

// checkAllAllocationFailures for the one public allocating entry point that is
// unit-reachable without a full logstore: `record`. Every allocation-failure
// path must surface `error.OutOfMemory`, never leak or crash.
test "undo journal: record survives injected allocation failures" {
    const io = testing.io;
    const Ctx = struct {
        fn run(gpa: std.mem.Allocator, dir: std.Io.Dir, aio: std.Io) !void {
            try record(gpa, aio, dir, &.{ "a.txt", "b.txt" });
        }
    };
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try checkout.write(io, tmp.dir, .{ .version = "1" ** 40, .branch = "trunk", .merge_parent = "", .special_op = "" });
    try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n");
    try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{ tmp.dir, io });
}