Artifact 1bca984064d6d75242d5b1156163864d7f16b67ccd20f98a1620b44fe8f90aac:
- File
src/update.zig
— part of check-in
[bb96853a0f]
at
2026-07-31 01:26:49
on branch sub-manifest
— update: materialize symlink worktree entries
A check-in's F card can name a symlink (§4.7.6-m5), but update refused one outright with error.SymlinkUnsupported, so any history carrying such a card could not be checked out, reverted, or updated to. The refusal was cheap to write and wrong: the reader already represents the entry, and only the worktree side lacked a branch for it.
materializeEntry and writePayload now create the link from the card's target bytes instead of writing a regular file containing them, and isCurrentState compares against .sym_link and the recorded target rather than the size and mode of a file that is not there. The managed-set walks accept .sym_link beside .file, so a materialized link reads as a tracked entry instead of an intruder the admission check rejects.
`zit revert` on a symlink card previously exited 1 naming the refusal. It now restores the entry, and the test reads the link back with readLink, which distinguishes native materialization from a regular file that merely holds the target's bytes. (user: dmestas, size: 111602) [annotate] [blame] [check-ins using]
//! Transactional working-tree materialization for `zit update CHECKIN`. //! //! This layer is deliberately the sole owner of update's filesystem effects: //! it validates and expands the requested manifest, stages every payload under //! an exclusive worktree-local transaction directory, proves the current tree //! clean, then writes `.zit-managed` before atomically committing //! `.zit-checkout`. const std = @import("std"); const artifact = @import("artifact.zig"); const checkout = @import("checkout.zig"); const logstore = @import("logstore.zig"); const managed = @import("managed.zig"); const repostate = @import("repostate.zig"); /// The single-level undo/redo journal (contracts/undo.md). Re-exported so the /// CLI reaches one instance — the same one this module writes through the /// `record` seam below (mutual import; Zig resolves it, no comptime cycle). pub const journal = @import("undo.zig"); /// How one materialization decides which working-tree manifest files to write. /// Fossil derives this from a repository setting; zit does NOT read settings — /// the opt-in is explicit and per checkout, so what a checkout promises is /// exactly what the user asked it for. `open --manifest` is the only verb that /// `set`s it; every other materialization `inherit`s what the checkout record /// already carries, which is what makes the files stay refreshed. pub const ManifestOpt = union(enum) { inherit, set: []const u8, }; /// The working-tree file each `checkout.manifest_selectors` character names, /// in the same order. const manifest_paths = [_][]const u8{ "manifest", "manifest.uuid", "manifest.tags" }; /// The line fossil appends to the on-disk `manifest` so the file cannot be /// mistaken for — or re-ingested as — the artifact it was copied from. const sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n"; /// The reason text every verb uses for `ApplyError.StateRecordInvalid`. Shared /// so the update, open and merge diagnostics cannot drift into disagreeing /// about what the error means — and, in particular, so none of them can /// silently collapse back into the `Corrupt` "repository is corrupt" wording. /// The two errors are different claims: `Corrupt` means stored bytes do not /// match the name they are stored under, while this one means a stored /// artifact was not usable as a check-in state record, which says nothing /// about the repository's integrity. pub const state_record_invalid_reason = "a stored artifact is not a usable check-in state record"; /// Errors intentionally describe the caller-visible update decision rather /// than leaking individual `std.Io` platform errors. pub const ApplyError = error{ InvalidCheckinId, CheckinNotFound, CheckinContentMissing, CheckinNameMismatch, TargetNotManifest, BaselineNotFound, BaselineContentMissing, BaselineNameMismatch, BaselineNotManifest, BaselineIsDelta, /// A stored artifact could not be turned into a check-in state record. /// This is a statement about one artifact's shape, not about repository /// integrity — see `Corrupt` for that. StateRecordInvalid, InvalidPath, ReservedPath, SymlinkUnsupported, CurrentStateInvalid, WorktreeNotClean, UnmanagedCollision, Storage, Corrupt, OutOfMemory, TransactionUnavailable, RollbackFailed, CleanupFailed, }; const ParentError = ApplyError || error{PathMissing}; const TestFault = enum { after_materialize, before_cleanup, merge_mid_apply, }; // Tests use these boundaries to deterministically exercise the transactional // branches that ordinary filesystem failures cannot reliably reach. Production // builds always return false and therefore retain their normal behavior. var test_fault: ?TestFault = null; fn shouldInjectTestFault(point: TestFault) bool { if (comptime @import("builtin").is_test) return test_fault == point; return false; } const PlanEntry = struct { path: []const u8, name: []const u8, digest: [logstore.max_hash_len]u8, digest_len: u8, mode: artifact.FileMode, fn deinit(self: *PlanEntry, gpa: std.mem.Allocator) void { gpa.free(self.path); gpa.free(self.name); self.* = undefined; } fn digestSlice(self: *const PlanEntry) []const u8 { return self.digest[0..self.digest_len]; } }; const Plan = struct { entries: std.ArrayList(PlanEntry) = .empty, fn deinit(self: *Plan, gpa: std.mem.Allocator) void { for (self.entries.items) |*entry| entry.deinit(gpa); self.entries.deinit(gpa); self.* = undefined; } fn add(self: *Plan, gpa: std.mem.Allocator, file: artifact.FCard) ApplyError!void { const name = file.hash orelse return; try validateWorktreePath(file.filename); var digest: [logstore.max_hash_len]u8 = undefined; const decoded = try decodeValidatedName(name, &digest); const path = gpa.dupe(u8, file.filename) catch return error.OutOfMemory; errdefer gpa.free(path); const owned_name = gpa.dupe(u8, name) catch return error.OutOfMemory; errdefer gpa.free(owned_name); self.entries.append(gpa, .{ .path = path, .name = owned_name, .digest = digest, .digest_len = @intCast(decoded.len), .mode = file.mode, }) catch return error.OutOfMemory; } fn validateTopology(self: *const Plan) ApplyError!void { if (self.entries.items.len < 2) return; for (self.entries.items[1..], self.entries.items[0 .. self.entries.items.len - 1]) |entry, previous| { if (std.mem.eql(u8, entry.path, previous.path)) return error.InvalidPath; if (isPathPrefix(previous.path, entry.path)) return error.InvalidPath; } } }; const LoadedManifest = struct { bytes: []u8, manifest: artifact.Manifest, /// Non-null when `parseForImport` normalized a legacy trailing-separator F /// card: the manifest borrows this copy, not `bytes` (read-path only). normalized: ?[]u8 = null, fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void { self.manifest.deinit(); if (self.normalized) |b| gpa.free(b); gpa.free(self.bytes); self.* = undefined; } }; const BaselineContext = struct { expected_hash: ?[]const u8 = null, manifest: ?*const artifact.Manifest = null, }; /// The derived-state result of `assembleState`. Published (with /// `assembleState`) as the DST-facing entry into update's log→state /// derivation, so `src/sim/assembly_sweep.zig` can drive the §4 /// unclassifiable-artifact rule against an independent oracle without going /// through the worktree-mutating `applyCheckin` path. pub const StateAssembly = struct { gpa: std.mem.Allocator, mem: *repostate.MemStore, state: repostate.RepoState, target_rid: repostate.Rid, pub fn deinit(self: *StateAssembly) void { self.state.deinit(); self.mem.deinit(); self.gpa.destroy(self.mem); self.* = undefined; } }; const OpenedParent = struct { dir: std.Io.Dir, name: []const u8, owned: bool, fn close(self: OpenedParent, io: std.Io) void { if (self.owned) self.dir.close(io); } }; const ExistingPath = union(enum) { missing, stat: std.Io.File.Stat, }; /// Directories made while materializing the target are held as borrowed /// slices into the already-owned plan. Capacity is reserved before any /// worktree mutation so recording a successful `createDir` cannot fail. const CreatedDirectories = struct { paths: std.ArrayList([]const u8) = .empty, fn init(gpa: std.mem.Allocator, plan: *const Plan) ApplyError!CreatedDirectories { var capacity: usize = 0; for (plan.entries.items) |entry| { for (entry.path) |byte| { if (byte == '/') capacity += 1; } } var result = CreatedDirectories{}; result.paths.ensureTotalCapacity(gpa, capacity) catch return error.OutOfMemory; return result; } fn deinit(self: *CreatedDirectories, gpa: std.mem.Allocator) void { self.paths.deinit(gpa); self.* = undefined; } fn record(self: *CreatedDirectories, path: []const u8) void { self.paths.appendAssumeCapacity(path); } fn remove(self: *const CreatedDirectories, io: std.Io, worktree: std.Io.Dir) ApplyError!void { var index = self.paths.items.len; while (index > 0) { index -= 1; const path = self.paths.items[index]; const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => continue, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.FileNotFound => {}, error.DirNotEmpty => return error.Storage, else => return error.Storage, }; } } }; /// Materialize canonical `target_id` into `worktree`. /// /// `target_id` is a public canonical artifact name. It is decoded to a raw /// digest for every `LogStore.lookup`; textual names are never used as log /// index keys. pub fn applyCheckin( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id: []const u8, manifest: ManifestOpt, ) ApplyError!void { var target = try buildPlan(gpa, log, target_id); defer target.deinit(gpa); // The effective manifest opt-in for this materialization AND for the record // it commits. Read before anything mutates, and held in a function-scoped // buffer the borrowed slice outlives. var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const manifest_flags = switch (manifest) { .set => |f| f, .inherit => blk: { const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err); break :blk if (cur) |c| c.manifest_flags else ""; }, }; var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer old_managed.deinit(); var old = try requireClean(gpa, io, worktree, log, &old_managed); defer old.deinit(gpa); var facts = try deriveTargetFacts(gpa, log, target_id, std.mem.indexOfScalar(u8, manifest_flags, 't') != null); defer facts.deinit(gpa); const branch = facts.branch; var desired_managed = managed.ManagedSet.init(gpa); defer desired_managed.deinit(); for (target.entries.items) |entry| { _ = desired_managed.insert(entry.path) catch |err| return mapManagedError(err); } // The managed set this update will commit is EXACTLY the target plan's // membership: one entry per plan path. `validateTopology` proved those paths // unique (and non-nesting), so every dedup insert added a distinct path — // the two can never disagree in count, the first half of the managed-set / // record / tree agreement the commit below depends on. std.debug.assert(desired_managed.iterate().len == target.entries.items.len); const txn_name = try createTransaction(gpa, io, worktree); defer gpa.free(txn_name); var transaction_live = true; defer if (transaction_live) worktree.deleteTree(io, txn_name) catch {}; var txn = worktree.openDir(io, txn_name, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer txn.close(io); txn.createDir(io, "next", .default_dir) catch return error.Storage; txn.createDir(io, "previous", .default_dir) catch return error.Storage; var next = txn.openDir(io, "next", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer next.close(io); var previous = txn.openDir(io, "previous", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer previous.close(io); // No worktree data is touched until both the desired and rollback payloads // are verified and durably staged in this same-filesystem transaction dir. try stagePlan(gpa, io, log, next, &target); try stagePlan(gpa, io, log, previous, &old); try preflight(gpa, io, worktree, &target, &old_managed); var created_directories = try CreatedDirectories.init(gpa, &target); defer created_directories.deinit(gpa); // Journal the undo image (B-undo-3/14): the pre-mutation bytes of every path // this update writes or deletes — `old_managed ∪ target` — plus the current // `.zit-checkout`/`.zit-managed`. Written and fsynced HERE, before // `commitMaterialization` moves the first working-file byte. Placed after // `requireClean`/`preflight`, so a rejected update never journals; the // rollback branch below clears it so a cleanly-undone update leaves no level. try recordUndoImage(gpa, io, worktree, &old_managed, &target); commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{ .version = target_id, .branch = branch, // `update` never carries a pending merge (that is `merge`'s job); a // plain check-out always clears it — and never a pending special merge. .merge_parent = "", .special_op = "", .manifest_flags = manifest_flags, }, .{ .log = log, .target_id = target_id, .tags = facts.tags }) catch |err| { rollbackMaterialization(gpa, io, worktree, previous, &target, &old, &old_managed, &created_directories) catch { // The staged `previous/` tree is the only complete recovery copy. // Retain it for recovery rather than letting the deferred cleanup // destroy it after a failed inverse. The undo journal is retained // too: it is the crash-recovery image for the half-applied tree. transaction_live = false; return error.RollbackFailed; }; // Clean rollback: the tree is back to pre-mutation, so there is nothing // to undo — drop the journal (keeps B-undo-9 honest). journal.clear(io, worktree); return err; }; // `.zit-checkout` is the commit point. A post-commit cleanup error is // reported but must never roll the committed tree or metadata back. transaction_live = false; if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed; worktree.deleteTree(io, txn_name) catch return error.CleanupFailed; } /// Build the affected-path set (`old_managed ∪ target`, deduplicated by a /// throwaway `ManagedSet`) and hand it to the undo journal (B-undo-14: exactly /// one seam per mutator). The set unifies both directions of the mutation — a /// path the update writes, a path it deletes — so `zit undo` restores every one. fn recordUndoImage( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, old_managed: *const managed.ManagedSet, target: *const Plan, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); for (old_managed.iterate()) |path| _ = affected.insert(path) catch |err| return mapManagedError(err); for (target.entries.items) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err); // The affected set is `old_managed ∪ target`, so it can never be smaller // than either operand: every path this update writes or deletes is journaled // before the first working byte moves (B-undo-3/14). std.debug.assert(affected.iterate().len >= target.entries.items.len); std.debug.assert(affected.iterate().len >= old_managed.iterate().len); journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // A journal device fault must abort BEFORE any working file moves: the // durability rule forbids mutating without a recoverable pre-image. error.Storage, error.Corrupt, error.Empty => return error.Storage, }; } fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!Plan { if (!artifact.validateName(target_id)) return error.InvalidCheckinId; var target_digest: [logstore.max_hash_len]u8 = undefined; const raw_target = try decodeValidatedName(target_id, &target_digest); const target_rid = log.lookup(raw_target) orelse return error.CheckinNotFound; const target_bytes = log.content(gpa, target_rid) catch |err| return mapContentError(err); if (target_bytes == null) return error.CheckinContentMissing; var target = LoadedManifest{ .bytes = target_bytes.?, .manifest = undefined, }; { errdefer gpa.free(target.bytes); if (!artifact.verify(target_id, target.bytes)) return error.CheckinNameMismatch; // Read path: verify identity over the original bytes, then tolerate the // legacy F-card trailing separator when parsing fossil-origin history. const im = artifact.parseForImport(gpa, target.bytes) catch |err| return mapTargetParseError(err); target.manifest = im.manifest; target.normalized = im.normalized; } defer target.deinit(gpa); if (target.manifest.kind != .manifest) return error.TargetNotManifest; // submanifests §5.1: a tree check-in's file set lives behind its S cards, so // walk it into a flat card list before §8 ever looks. §4.4 makes this // exclusive with the B-card branch below (never both B and S), so a check-in // is walked or resolved, never both. try flattenIfTree(gpa, log, &target.manifest); var baseline: ?LoadedManifest = null; defer if (baseline) |*item| item.deinit(gpa); var baseline_context = BaselineContext{}; if (target.manifest.b) |b| { var digest: [logstore.max_hash_len]u8 = undefined; const raw = decodeValidatedName(b.hash, &digest) catch return error.BaselineNotFound; const rid = log.lookup(raw) orelse return error.BaselineNotFound; const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return error.BaselineContentMissing; var item = LoadedManifest{ .bytes = bytes.?, .manifest = undefined }; { errdefer gpa.free(item.bytes); if (!artifact.verify(b.hash, item.bytes)) return error.BaselineNameMismatch; const im = artifact.parseForImport(gpa, item.bytes) catch |err| return mapBaselineParseError(err); item.manifest = im.manifest; item.normalized = im.normalized; } if (item.manifest.kind != .manifest) { item.deinit(gpa); return error.BaselineNotManifest; } baseline = item; // submanifests §5.4: "a delta manifest MAY name a tree manifest in its B // card ... the baseline cursor of the merge-walk ... is the enumeration // of Section 5.1." Flattening here makes that composition free. try flattenIfTree(gpa, log, &baseline.?.manifest); 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; } /// The §8.2 view of a parsed manifest: its B-card hash and its file set — its /// own F run, or, for a tree manifest, the walked enumeration `flattenIfTree` /// derived (submanifests §5.1, whose closing paragraph requires downstream /// consumers to "MUST NOT distinguish a walked enumeration from a flat or delta /// one"). fn manifestFiles(manifest: *const artifact.Manifest) artifact.ManifestFiles { // submanifests §9.3 seam assertion: a tree manifest's `f` run is empty, so a // view built before the walk would silently plan an empty checkout. std.debug.assert(manifest.s.len == 0 or manifest.flattened != null); return .{ .baseline_hash = if (manifest.b) |b| b.hash else null, .files = manifest.flattened orelse manifest.f, }; } /// submanifests §5.2 resolver over the LogStore: locate the artifact an S card /// names, verify it against that name, parse it, and hand back its view only /// when it really is a SUBMANIFEST. Absent, unverifiable-as-named, unparseable, /// and wrong-type all collapse to null — §5.2's single hard failure, modelled on /// §8.3's B-card collapse. Genuine store faults latch in `fault` so the caller /// re-raises the real error instead of reporting a resolution gap. /// /// Each sub-manifest is retained until `deinit` because the walk borrows its /// cards; `flattenTree` copies what it emits, so the scratch dies with the call. const TreeStore = struct { gpa: std.mem.Allocator, log: *logstore.LogStore, scratch: std.ArrayList(LoadedManifest) = .empty, fault: ?ApplyError = null, fn resolve(context: *anyopaque, hash: []const u8) ?artifact.SubManifestView { const self: *TreeStore = @ptrCast(@alignCast(context)); if (self.fault != null) return null; // already faulted: read no further var digest: [logstore.max_hash_len]u8 = undefined; const raw = decodeValidatedName(hash, &digest) catch return null; // never stored const rid = self.log.lookup(raw) orelse return null; // §5.2: absent const bytes = (self.log.content(self.gpa, rid) catch |err| { self.fault = mapContentError(err); return null; }) orelse return null; // §5.2: interned phantom, no content // Read path: identity over the original bytes first, as every other // artifact this file loads does. if (!artifact.verify(hash, bytes)) { self.gpa.free(bytes); self.fault = error.Corrupt; return null; } var im = artifact.parseForImport(self.gpa, bytes) catch |err| { self.gpa.free(bytes); if (err == error.OutOfMemory) self.fault = error.OutOfMemory; return null; // §5.2: unparseable ⇒ unresolvable }; if (im.manifest.kind != .submanifest) { im.deinit(self.gpa); self.gpa.free(bytes); return null; // §3.6: a reference to any other type fails the walk } var loaded = LoadedManifest{ .bytes = bytes, .manifest = im.manifest, .normalized = im.normalized, }; self.scratch.append(self.gpa, loaded) catch { loaded.deinit(self.gpa); self.fault = error.OutOfMemory; return null; }; const stored = &self.scratch.items[self.scratch.items.len - 1]; return .{ .files = stored.manifest.f, .subs = stored.manifest.s }; } fn deinit(self: *TreeStore) void { for (self.scratch.items) |*m| m.deinit(self.gpa); self.scratch.deinit(self.gpa); self.* = undefined; } }; /// submanifests §5.1/§5.4: derive `manifest`'s complete file set when it is a /// tree manifest, leaving a flat or delta manifest untouched. Runs before the §8 /// enumeration below, so `artifact.enumerate` and the merge walk stay unchanged /// (§5.4: "the merge-walk itself is unchanged"). /// /// Every §5.1 fault becomes `BaselineNotFound`, the code this file already uses /// for §8.3's `BaselineUnresolvable`. §5.2 puts S resolution "under the same two /// disciplines" as the B card and mandates hard failure without mandating a /// distinct code; the five-way `TreeError` distinction the conformance vectors /// test lives at the artifact layer, where it is observable. fn flattenIfTree( gpa: std.mem.Allocator, log: *logstore.LogStore, manifest: *artifact.Manifest, ) ApplyError!void { if (manifest.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one var store = TreeStore{ .gpa = gpa, .log = log }; defer store.deinit(); manifest.flattenTree(.{ .ctx = &store, .resolveFn = TreeStore.resolve }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { if (store.fault) |f| return f; // a real store fault, not a §5.2 gap return error.BaselineNotFound; // §5.2 hard failure }, }; std.debug.assert(store.fault == null); // a latched fault always fails the walk std.debug.assert(manifest.flattened != null); // §5.2: never a partial file set } fn resolveBaseline(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const baseline: *const BaselineContext = @ptrCast(@alignCast(context)); const expected = baseline.expected_hash orelse return null; const manifest = baseline.manifest orelse return null; if (!std.mem.eql(u8, expected, hash)) return null; return 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); // An UNBORN record (`version == ""`) names no check-in, so there is no plan // to build against — the same situation as an absent record, and handled by // the same arm so the two cannot drift apart. Unborn is the normal, expected // state of every fresh repository: `init` writes it unconditionally (and // `clone` delegates repo creation to `init`), so without this the first // `update` after a clone would hand the empty version to `buildPlan`, fail // `artifact.validateName("")`, and report `CurrentStateInvalid` — a clean, // correct tree diagnosed as broken. if (current == null or current.?.version.len == 0) { if (current_managed.iterate().len != 0) return error.WorktreeNotClean; // A record that names no check-in yet claims a merge parent or a // pending special operation is incoherent, not a fresh clone: both // describe work in progress against a check-in that the record does not // name. Only a present record can carry them. if (current) |c| { if (c.merge_parent.len != 0 or c.special_op.len != 0) return error.CurrentStateInvalid; } return Plan{}; } var plan = buildPlan(gpa, log, current.?.version) catch |err| switch (err) { error.OutOfMemory, error.Storage, error.Corrupt => return err, else => return error.CurrentStateInvalid, }; errdefer plan.deinit(gpa); if (!sameMembership(&plan, current_managed)) return error.WorktreeNotClean; for (plan.entries.items) |entry| { if (!try pathMatchesEntry(gpa, io, worktree, log, &entry)) return error.WorktreeNotClean; } // Postcondition: a returned plan is the current check-in's, and its // membership equals the recorded managed set — the guard above rejected any // drift, so on every success path the record and the managed set agree. std.debug.assert(sameMembership(&plan, current_managed)); return plan; } fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool { const paths = current.iterate(); if (paths.len != plan.entries.items.len) return false; for (paths, plan.entries.items) |path, entry| { if (!std.mem.eql(u8, path, entry.path)) return false; } return true; } fn pathMatchesEntry( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, entry: *const PlanEntry, ) ApplyError!bool { const existing = try statExisting(io, worktree, entry.path); const stat = switch (existing) { .missing => return false, .stat => |value| value, }; switch (entry.mode) { .symlink => if (stat.kind != .sym_link) return false, .regular, .executable => if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false, } const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid; const expected = log.content(gpa, rid) catch |err| return mapContentError(err); if (expected == null) return error.CurrentStateInvalid; defer gpa.free(expected.?); if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid; const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return false, else => return @errorCast(err), }; defer parent.close(io); switch (entry.mode) { .symlink => { const actual = gpa.alloc(u8, std.math.add(usize, expected.?.len, 1) catch return error.OutOfMemory) catch return error.OutOfMemory; defer gpa.free(actual); const n = parent.dir.readLink(io, parent.name, actual) catch return false; return n == expected.?.len and std.mem.eql(u8, actual[0..n], expected.?); }, .regular, .executable => { if (stat.size != expected.?.len) return false; var file = parent.dir.openFile(io, parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return false; defer file.close(io); const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory; defer gpa.free(actual); const n = file.readPositionalAll(io, actual, 0) catch return error.Storage; return n == actual.len and std.mem.eql(u8, actual, expected.?); }, } } /// What one materialization needs out of the derived repository state: the /// branch the record commits, and — only when the `t` selector asked for it — /// the check-in's symbolic tag names. Both come from ONE state derive; a second /// pass just for the tags would double the most expensive read on the path. const TargetFacts = struct { branch: []u8, /// One symbolic tag name per line, newline-terminated; empty when `t` was /// not requested (and when the check-in carries no symbolic tag). tags: []u8, fn deinit(self: *TargetFacts, gpa: std.mem.Allocator) void { gpa.free(self.branch); gpa.free(self.tags); self.* = undefined; } }; fn deriveTargetFacts( gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, want_tags: bool, ) ApplyError!TargetFacts { var assembly = try assembleState(gpa, log, target_id); defer assembly.deinit(); var config = log.readConfig(gpa) catch |err| return mapContentError(err); defer if (config) |*owned| owned.deinit(gpa); // Storage deliberately leaves an absent config to its caller. The // repository-state convention is the same `trunk` fallback used by the // existing state tests; a present config always overrides it. const main_branch = if (config) |owned| owned.config.default_branch else "trunk"; const branch = assembly.state.branchOf(assembly.target_rid, main_branch); const owned_branch = gpa.dupe(u8, branch) catch return error.OutOfMemory; errdefer gpa.free(owned_branch); var tags: []u8 = &.{}; if (want_tags) { // Fossil's `manifest.tags` layout: the branch line, then one `tag` line // per SYMBOLIC tag active on this check-in. Reserved non-symbolic names // ("branch", "closed", ...) are not names a user checks out and are // excluded, as fossil excludes them. Fossil emits its tag lines in tag- // creation order, which no other repository can reproduce; `tagsOf` // returns the active rows name-ascending, so zit's file is the same // lines in a deterministic order. const sym_prefix = "sym-"; const entries = assembly.state.tagsOf(gpa, assembly.target_rid) catch return error.OutOfMemory; defer gpa.free(entries); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); out.print(gpa, "branch {s}\n", .{branch}) catch return error.OutOfMemory; for (entries) |e| { if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue; out.print(gpa, "tag {s}\n", .{e.name[sym_prefix.len..]}) catch return error.OutOfMemory; } tags = out.toOwnedSlice(gpa) catch return error.OutOfMemory; } return .{ .branch = owned_branch, .tags = tags }; } /// State-record classification is intentionally conservative: any successful /// classified manifest is crosslinked, any successful classified control /// artifact ingests only tag effects, and all other successful classifications /// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque: /// the log frame format has no structural-kind bit to distinguish a blob from /// malformed cards. Integrity failures remain fatal. /// /// A classification failure is NOT fatal, and is not evidence of damage. A /// conforming repository contains artifacts a conforming parser declines: the /// artifact grammar requires the U card on a WIKI artifact, fossil elides it /// when the submitter is "nobody", and fossil stores and syncs the result, so a /// clone of an intact fossil repository carries such artifacts. Repository /// state §4 separates the two conditions by the name: bytes that hash to the /// name they are stored under are stored correctly and merely unclassifiable, /// so they are opaque to state and skipped; bytes that do not are an integrity /// failure and stay fatal. Verification is what distinguishes them, so it runs /// before the skip rather than after — see the arm below. /// /// This is why assembly must not answer a decline with a corruption claim: the /// verified name has already ruled damage out. pub fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly { const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory; mem.* = repostate.MemStore.init(gpa); errdefer { mem.deinit(); gpa.destroy(mem); } var state = repostate.RepoState.init(gpa, mem.store()); errdefer state.deinit(); var target_rid: ?repostate.Rid = null; const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory; defer gpa.free(persisted_rids); var raw_name: [64]u8 = undefined; for (persisted_rids) |rid| { const name = encodeName(log.hashOf(rid), &raw_name); const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) continue; defer gpa.free(bytes.?); // Read path: repostate recovery over persisted (fossil-origin) history // tolerates the legacy F-card trailing separator. var parsed = artifact.parseForImport(gpa, bytes.?) catch |err| switch (err) { error.SyntaxError => continue, error.OutOfMemory => return error.OutOfMemory, // Classification declined the artifact. Repository state §4 splits // this from damage by the name: bytes that hash to the name they // are stored under arrived and were stored correctly, so the only // event is that this reader's grammar does not admit them. Such an // artifact is opaque to state — it is skipped exactly as a // non-participating type is. Bytes that do NOT hash to their name // are the other condition, and stay fatal. // // A conforming repository reaches this: the artifact grammar makes // the U card required on a WIKI artifact, fossil elides it when the // submitter is "nobody", and fossil stores and syncs the result. else => { if (artifact.verify(name, bytes.?)) continue; return error.Corrupt; }, }; defer parsed.deinit(gpa); switch (parsed.manifest.kind) { .manifest => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try manifestRecord(gpa, name, &parsed.manifest); defer { gpa.free(record.parents); gpa.free(record.tags); } const state_rid = state.crosslink(record) catch return error.OutOfMemory; if (std.mem.eql(u8, name, target_id)) target_rid = state_rid; }, .control => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try controlRecord(gpa, &parsed.manifest); defer gpa.free(record.tags); _ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory; }, else => {}, } } return .{ .gpa = gpa, .mem = mem, .state = state, // Unreachable on the shipped path, and deliberately not given its own // variant for that reason. `applyCheckin` runs `buildPlan` on this same // `target_id` first, and `buildPlan` already proved the name valid, the // rid present, the content non-null, `artifact.verify` passing, // `parseForImport` succeeding and `kind == .manifest`. The loop above // re-parses those same bytes with the same two tests, and // `ridsInPersistedOrder` filters only phantoms — which a non-null // `content` already excluded — so the target is always crosslinked by // the time this runs. It stays a state-record failure rather than a // user error: were it ever to fire it would mean the assembler and the // log disagree, which is not something the caller named wrongly. .target_rid = target_rid orelse return error.StateRecordInvalid, }; } const StateRecordParts = struct { mtime: repostate.Mtime, parents: []const []const u8 = &.{}, tags: []const repostate.TagOp = &.{}, }; fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record { const parts = try stateRecordParts(gpa, manifest, true); return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags }; } fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts { return stateRecordParts(gpa, manifest, false); } fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts { const date = manifest.d orelse return error.StateRecordInvalid; const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid; var parents = std.ArrayList([]const u8).empty; errdefer parents.deinit(gpa); if (include_parents) { for (manifest.p) |card| { var it = card.hashes(); while (it.next()) |hash| { parents.append(gpa, hash) catch return error.OutOfMemory; } } } const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory; errdefer gpa.free(tags); for (manifest.t, tags) |card, *out| { out.* = .{ .type = switch (card.sign) { .add => .singleton, .cancel => .cancel, .propagate => .propagate, }, .name = card.name, .target = card.target, .value = card.value, }; } return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags }; } 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, .sym_link => if (current_managed.contains(parent)) { replaces_managed_ancestor = true; break; } else return error.UnmanagedCollision, else => return error.UnmanagedCollision, }, } component_end = slash + 1; } if (replaces_managed_ancestor) continue; const existing = try statExisting(io, worktree, entry.path); switch (existing) { .missing => {}, .stat => |stat| switch (stat.kind) { .file, .sym_link => if (!current_managed.contains(entry.path)) return error.UnmanagedCollision, .directory => if (!try directoryContainsOnlyManaged(gpa, io, worktree, entry.path, current_managed)) return error.UnmanagedCollision, else => return error.UnmanagedCollision, }, } } } fn commitMaterialization( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, next: std.Io.Dir, previous: std.Io.Dir, target: *const Plan, old: *const Plan, desired_managed: *const managed.ManagedSet, old_managed: *const managed.ManagedSet, created_directories: *CreatedDirectories, next_checkout: checkout.CheckoutState, manifest: ManifestSource, ) ApplyError!void { _ = previous; _ = old_managed; // Preconditions of the commit seam — the managed-set / record / staged-tree // agreement this transition rests on, self-guarded here rather than trusted // from the caller: // (iv) the record about to be written names EXACTLY the check-in whose // payload `next` was staged from, so a completed commit never leaves // a record pointing at a materialization that did not run; and // the managed set to be written is that check-in's membership (one entry // per target plan path). std.debug.assert(std.mem.eql(u8, next_checkout.version, manifest.target_id)); std.debug.assert(desired_managed.iterate().len == target.entries.items.len); try removePlanFiles(io, worktree, old, true); try materializePlan(gpa, io, next, worktree, target, created_directories); if (shouldInjectTestFault(.after_materialize)) return error.Storage; // Before the metadata writes, so a failure here still takes the ordinary // rollback rather than stranding a committed record beside a half-written // convenience file. try writeManifestFiles(gpa, io, worktree, next_checkout, desired_managed, manifest); desired_managed.write(io, worktree) catch |err| return mapManagedError(err); checkout.write(io, worktree, next_checkout) catch |err| return mapCheckoutError(err); } /// Where `writeManifestFiles` reads its payloads from: the log holding the /// check-in artifact, the check-in's own name, and the tag listing already /// derived for this target (empty unless `t` was selected). const ManifestSource = struct { log: *logstore.LogStore, target_id: []const u8, tags: []const u8, }; /// Write the working-tree manifest files `next_checkout.manifest_flags` selects. /// No RFC section behind this: it is fossil's `manifest` setting re-expressed as /// an explicit per-checkout opt-in, and the file contents match fossil's byte /// for byte so a build that reads them cannot tell the two apart. /// /// `r` is the check-in artifact followed by fossil's sterilizing line, `u` is /// the check-in name plus a newline, `t` is the branch line and one line per /// symbolic tag. A selector whose file name is TRACKED by the target check-in /// is skipped: fossil has that guard and the reason is the same — a versioned /// file the user committed outranks a convenience file, and silently replacing /// it would lose data the repository is responsible for. No selector means no /// write at all, which is the default. fn writeManifestFiles( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, next_checkout: checkout.CheckoutState, tracked: *const managed.ManagedSet, src: ManifestSource, ) ApplyError!void { const flags = next_checkout.manifest_flags; if (flags.len == 0) return; std.debug.assert(flags.len <= checkout.manifest_selectors.len); for (flags) |selector| { const index = std.mem.indexOfScalar(u8, checkout.manifest_selectors, selector) orelse return error.CurrentStateInvalid; // a record carrying a selector no writer knows const path = manifest_paths[index]; if (tracked.contains(path)) continue; switch (selector) { 'r' => { var digest: [logstore.max_hash_len]u8 = undefined; const raw = try decodeValidatedName(src.target_id, &digest); const rid = src.log.lookup(raw) orelse return error.CheckinNotFound; const bytes = src.log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return error.CheckinContentMissing; defer gpa.free(bytes.?); // The file is the check-in artifact verbatim, so it must still // hash to its own name before anything is written. if (!artifact.verify(src.target_id, bytes.?)) return error.CheckinNameMismatch; // ...and then the sterilizing line, byte-for-byte fossil's. // Without it the file on disk IS a valid artifact, so adding it // to a check-in would commit a second copy of that check-in // under a name that is not its own. Fossil defends against that // and so must zit; a build reading the D or C card is unaffected. var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); out.appendSlice(gpa, bytes.?) catch return error.OutOfMemory; out.appendSlice(gpa, sterilizer) catch return error.OutOfMemory; try writePayload(io, worktree, path, out.items, .regular, false, null); }, 'u' => { // 64 hex digits is the longest canonical name (§3.2); the // `buildPlan` gate has already proven `target_id` is one. var line: [2 * logstore.max_hash_len + 1]u8 = undefined; std.debug.assert(artifact.validateName(src.target_id)); @memcpy(line[0..src.target_id.len], src.target_id); line[src.target_id.len] = '\n'; try writePayload(io, worktree, path, line[0 .. src.target_id.len + 1], .regular, false, null); }, 't' => try writePayload(io, worktree, path, src.tags, .regular, false, null), else => unreachable, // the selector table above is exhaustive } } } fn rollbackMaterialization( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, previous: std.Io.Dir, target: *const Plan, old: *const Plan, old_managed: *const managed.ManagedSet, created_directories: *const CreatedDirectories, ) ApplyError!void { try removePlanFiles(io, worktree, target, false); try created_directories.remove(io, worktree); try materializePlan(gpa, io, previous, worktree, old, null); old_managed.write(io, worktree) catch |err| return mapManagedError(err); } fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void { for (plan.entries.items) |entry| { const removed = blk: { const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => break :blk false, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => break :blk false, else => return error.Storage, }; break :blk true; }; if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path); } } /// Deletes only empty ancestors of a removed managed file. A non-empty /// directory is never recursed into, so unmanaged entries remain untouched. fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void { var index = path.len; while (index > 0) { index -= 1; if (path[index] != '/') continue; const ancestor = path[0..index]; const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) { error.PathMissing => continue, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.FileNotFound, error.DirNotEmpty => {}, else => return error.Storage, }; } } fn materializePlan( gpa: std.mem.Allocator, io: std.Io, stage: std.Io.Dir, worktree: std.Io.Dir, plan: *const Plan, created_directories: ?*CreatedDirectories, ) ApplyError!void { for (plan.entries.items) |entry| { try prepareDestination(io, worktree, entry.path); const source_parent = openParentNoFollow(io, stage, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer source_parent.close(io); switch (entry.mode) { .symlink => { const stat = source_parent.dir.statFile(io, source_parent.name, .{ .follow_symlinks = false }) catch return error.Storage; if (stat.kind != .sym_link) return error.Storage; const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; defer gpa.free(bytes); const n = source_parent.dir.readLink(io, source_parent.name, bytes) catch return error.Storage; if (n != bytes.len) return error.Storage; try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories); }, .regular, .executable => { var source = source_parent.dir.openFile(io, source_parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return error.Storage; defer source.close(io); const stat = source.stat(io) catch return error.Storage; const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; defer gpa.free(bytes); const n = source.readPositionalAll(io, bytes, 0) catch return error.Storage; if (n != bytes.len) return error.Storage; try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories); }, } } } fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void { const existing = try statExisting(io, worktree, path); switch (existing) { .missing => {}, .stat => |stat| switch (stat.kind) { .directory => { const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.DirNotEmpty => return error.UnmanagedCollision, else => return error.Storage, }; }, else => return error.UnmanagedCollision, }, } } fn writePayload( io: std.Io, root: std.Io.Dir, path: []const u8, bytes: []const u8, mode: artifact.FileMode, exclusive: bool, created_directories: ?*CreatedDirectories, ) ApplyError!void { const parent = openParentNoFollow(io, root, path, true, created_directories) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); switch (mode) { .symlink => { if (!exclusive) { parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => {}, else => return error.Storage, }; } parent.dir.symLink(io, bytes, parent.name, .{}) catch return error.Storage; }, .regular, .executable => { var file = parent.dir.createFile(io, parent.name, .{ .read = false, .exclusive = exclusive, .truncate = true, .permissions = switch (mode) { .regular => .default_file, .executable => .executable_file, .symlink => unreachable, }, .resolve_beneath = true, }) catch return error.Storage; defer file.close(io); file.writePositionalAll(io, bytes, 0) catch return error.Storage; file.sync(io) catch return error.Storage; }, } } /// The closed error set `restoreFile` surfaces: a storage failure while /// materializing the requested working-tree entry. `SymlinkUnsupported` stays /// in the public set for compatibility but is no longer returned for F-cards. pub const RestoreError = error{ Storage, SymlinkUnsupported }; /// Overwrite the single worktree file `path` under `root` with `bytes` at /// `mode` (artifact §8.4 effective-file restore), creating any missing parent /// directories. Unlike the transactional `applyCheckin`, this is a targeted, /// non-exclusive write of ONE already-resolved file: `zit revert` reconstructs /// the check-in's blob and hands it here, leaving the managed set untouched. /// Symlink targets are created from `bytes` exactly; all materialization /// failures surface as `Storage`. Regular-file writes are fsync'd before /// returning. pub fn restoreFile( io: std.Io, root: std.Io.Dir, path: []const u8, bytes: []const u8, mode: artifact.FileMode, ) RestoreError!void { writePayload(io, root, path, bytes, mode, false, null) catch |e| switch (e) { error.SymlinkUnsupported => return error.SymlinkUnsupported, else => return error.Storage, }; } /// Delete the single worktree file `path` under `root`, pruning any parent /// directories the delete leaves empty — the symmetric inverse of `restoreFile` /// (`zit undo` uses it to remove a file the undone mutation created). Reuses the /// same no-follow parent walk and empty-ancestor prune the transactional path /// uses; an already-absent path is success, not an error. Fsync of the /// containing directory is the OS's to schedule; the delete is durable-ordered /// after the journal swap that authorized it. pub fn removeFile(io: std.Io, root: std.Io.Dir, path: []const u8) RestoreError!void { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return, // parent gone => nothing to delete else => return error.Storage, }; defer parent.close(io); parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => return, // already absent else => return error.Storage, }; pruneEmptyPlanAncestors(io, root, path) catch return error.Storage; } // --------------------------------------------------------------------------- // Merge materialization (checkout-layer seam for `zit merge`). // // The CLI computes a three-way merge purely, then hands the outcome set here as // `MergeEntry` values. This layer owns the transactional filesystem effects, // reusing the same journal + write-temp+fsync + rollback primitives // `applyCheckin` uses. It never inspects bytes for conflict markers — surfacing // conflicts is the CLI's job. // --------------------------------------------------------------------------- /// Verify the working tree is clean against its current checkout — the same /// `requireClean` gate `applyCheckin` runs (every managed file matches its /// checked-in content and the membership has not drifted), discarding the Plan. /// `zit merge`'s refusal gate calls this to reject a dirty tree (B-merge-14) /// before any mutation; a clean tree returns void, a dirty one `WorktreeNotClean`. /// No mutation. pub fn requireCleanTree( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, ) ApplyError!void { var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer current_managed.deinit(); var plan = try requireClean(gpa, io, worktree, log, ¤t_managed); plan.deinit(gpa); } /// One per-path effect of a computed merge. `write` carries the merged bytes and /// the CURRENT-side attribute (`perm`: null/"" = regular, "x" = executable, /// "l" = symlink — B-merge-23, the working file keeps its side's mode); `delete` /// removes the path. pub const MergeAction = union(enum) { write: struct { bytes: []const u8, perm: ?[]const u8 }, delete, }; /// A single path plus the action to apply to it. pub const MergeEntry = struct { path: []const u8, action: MergeAction, }; /// Journal-armed, transactional materialization of a computed merge outcome set, /// then the new `.zit-managed` and `.zit-checkout`. Order (B-merge-3): (1) /// journal the pre-images of every affected path ∪ the current checkout ∪ the /// current managed set, fsynced before the first byte moves; (2) write the new /// checkout (the caller sets `merge_parent` to the other side's hash) and the /// new managed set — the pending merge and its membership are recorded FIRST so /// a crash mid-apply still names them for `zit undo`; (3) materialize each /// entry. Any failure during (3) rolls files, the managed set AND the checkout /// back to the pre-merge state and drops the journal, exactly as `applyCheckin` /// rolls back. /// /// The managed-set mutation is DERIVED here from the same `entries` stream that /// drives the filesystem effects, never passed in alongside it: a `.write` /// registers its path (insert is idempotent, so an ordinary edit-merge is a /// no-op) and a `.delete` deregisters it. One stream, one truth — a merge that /// adds a file cannot leave it untracked (blocker B5) and a merge that deletes /// one cannot leave a phantom "missing" entry behind. pub fn applyMerge( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, new_checkout: checkout.CheckoutState, ) ApplyError!void { for (entries) |entry| try validateWorktreePath(entry.path); // (1) Arm the undo journal before any working byte moves (B-merge-3): the // pre-mutation bytes of every affected path plus the current // `.zit-checkout`/`.zit-managed`, fsynced HERE. A device fault aborts // before mutating — no recoverable pre-image, no mutation. try recordMergeUndoImage(gpa, io, worktree, entries); // In-process rollback images: the pre-merge bytes/mode/presence of each // affected path, plus the pre-merge checkout and managed set, so a mid-apply // failure restores the tree exactly. The journal remains the crash-recovery // image. var images = try captureMergeImages(gpa, io, worktree, entries); defer images.deinit(gpa); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const old_checkout = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err); // Two independent reads rather than one rebuilt copy: the on-disk record // carries more than the membership (a pending `mv` rename), and rebuilding // `new` from `old.iterate()` would silently drop it. var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer old_managed.deinit(); var new_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer new_managed.deinit(); // Both sets are a fresh read of the SAME on-disk `.zit-managed`, so before // the outcome stream is folded into `new_managed` they hold identical // membership — `old_managed` is the untouched pre-merge baseline the // rollback restores to. std.debug.assert(new_managed.iterate().len == old_managed.iterate().len); try applyMergeMembership(&new_managed, entries); // (2) Record the pending merge FIRST (checkout carries merge_parent = other), // then the membership it implies. A failure here has moved no working // byte, but the checkout may already name the merge, so roll both back. checkout.write(io, worktree, new_checkout) catch |err| return mapCheckoutError(err); new_managed.write(io, worktree) catch |err| { rollbackMerge(io, worktree, &.{}, old_checkout, &old_managed) catch return error.RollbackFailed; journal.clear(io, worktree); return mapManagedError(err); }; // (3) Materialize the outcome set; roll files + managed + checkout back on // any failure. materializeMergeEntries(io, worktree, entries) catch |err| { rollbackMerge(io, worktree, images.items, old_checkout, &old_managed) catch { // The tree is half-restored; the undo journal is the only complete // recovery image, so retain it and report the failed inverse. return error.RollbackFailed; }; // Clean rollback: the tree is back to pre-merge, so drop the journal — // a cleanly-abandoned merge leaves no spurious undo level (B-merge-3). journal.clear(io, worktree); return err; }; } /// Folds the merge outcome set into `set`'s membership: every `.write` path is /// inserted (idempotent — an ordinary edit-merge changes nothing) and every /// `.delete` path removed (absent is a no-op). No I/O. fn applyMergeMembership(set: *managed.ManagedSet, entries: []const MergeEntry) ApplyError!void { for (entries) |entry| switch (entry.action) { .write => _ = set.insert(entry.path) catch |err| return mapManagedError(err), .delete => _ = set.remove(entry.path), }; } /// Build the affected-path set (deduplicated) and hand it to the undo journal. fn recordMergeUndoImage( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); for (entries) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err); journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt, error.Empty => return error.Storage, }; } const MergeImage = struct { /// Borrows the caller's `entry.path`, which outlives this call. path: []const u8, present: bool, mode: artifact.FileMode, /// Pre-merge bytes when `present`; `gpa`-owned. Empty otherwise. blob: []const u8, }; const MergeImages = struct { items: []MergeImage, fn deinit(self: *MergeImages, gpa: std.mem.Allocator) void { for (self.items) |img| if (img.present) gpa.free(img.blob); gpa.free(self.items); self.* = undefined; } }; fn captureMergeImages( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, ) ApplyError!MergeImages { const items = gpa.alloc(MergeImage, entries.len) catch return error.OutOfMemory; var filled: usize = 0; errdefer { for (items[0..filled]) |img| if (img.present) gpa.free(img.blob); gpa.free(items); } for (entries) |entry| { items[filled] = try captureMergeImage(gpa, io, worktree, entry.path); filled += 1; } // Exactly one rollback pre-image per affected path: the roll-back walk below // must be able to restore every path the merge is about to touch, so the // image count and the outcome-set length can never diverge. std.debug.assert(filled == entries.len); return .{ .items = items }; } fn captureMergeImage(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!MergeImage { const absent = MergeImage{ .path = path, .present = false, .mode = .regular, .blob = "" }; const existing = try statExisting(io, worktree, path); const stat = switch (existing) { .missing => return absent, .stat => |value| value, }; if (stat.kind != .file) return absent; const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => return absent, else => return @errorCast(err), }; defer parent.close(io); var file = parent.dir.openFile(io, parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return error.Storage; defer file.close(io); const blob = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; errdefer gpa.free(blob); const n = file.readPositionalAll(io, blob, 0) catch return error.Storage; if (n != blob.len) return error.Storage; const mode: artifact.FileMode = if (statIsExecutable(stat)) .executable else .regular; return .{ .path = path, .present = true, .mode = mode, .blob = blob }; } fn materializeMergeEntries(io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry) ApplyError!void { for (entries, 0..) |entry, i| { // Deterministic mid-apply fault (tests only): the first entry is applied, // then this fires, so rollback must restore a partially-mutated tree. if (i == 1 and shouldInjectTestFault(.merge_mid_apply)) return error.Storage; switch (entry.action) { .write => |w| { const mode = try modeFromPerm(w.perm); try writePayload(io, worktree, entry.path, w.bytes, mode, false, null); }, .delete => removeFile(io, worktree, entry.path) catch |err| switch (err) { error.SymlinkUnsupported => return error.SymlinkUnsupported, error.Storage => return error.Storage, }, } } } fn rollbackMerge( io: std.Io, worktree: std.Io.Dir, images: []const MergeImage, old_checkout: ?checkout.CheckoutState, old_managed: *const managed.ManagedSet, ) ApplyError!void { for (images) |img| { if (img.present) { restoreFile(io, worktree, img.path, img.blob, img.mode) catch return error.Storage; } else { removeFile(io, worktree, img.path) catch return error.Storage; } } // The membership mutation is part of the merge, so its inverse is part of // the rollback: a rolled-back merge must leave nothing registered that it // registered, and nothing deregistered that it removed. old_managed.write(io, worktree) catch |err| return mapManagedError(err); if (old_checkout) |ck| { checkout.write(io, worktree, ck) catch |err| return mapCheckoutError(err); } else { worktree.deleteFile(io, checkout.filename) catch |err| switch (err) { error.FileNotFound => {}, else => return error.Storage, }; } } /// Maps a fossil-style perm token (the current side's attribute) to a file mode: /// null/"" = regular, "x" = executable, "l" = symlink (rejected downstream as /// `SymlinkUnsupported`). Any other token is a malformed outcome (`Corrupt`). fn modeFromPerm(perm: ?[]const u8) ApplyError!artifact.FileMode { const token = perm orelse return .regular; if (token.len == 0) return .regular; if (std.mem.eql(u8, token, "x")) return .executable; if (std.mem.eql(u8, token, "l")) return .symlink; return error.Corrupt; } fn statIsExecutable(stat: std.Io.File.Stat) bool { if (comptime std.Io.File.Permissions.has_executable_bit) { return stat.permissions.toMode() & 0o111 != 0; } return false; } fn statExisting(io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return .missing, else => return @errorCast(err), }; defer parent.close(io); const stat = parent.dir.statFile(io, parent.name, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => return .missing, else => return error.Storage, }; return .{ .stat = stat }; } fn openParentNoFollow( io: std.Io, root: std.Io.Dir, path: []const u8, create: bool, created_directories: ?*CreatedDirectories, ) ParentError!OpenedParent { var current = root; var owns_current = false; errdefer if (owns_current) current.close(io); var start: usize = 0; while (std.mem.indexOfScalarPos(u8, path, start, '/')) |slash| { const component = path[start..slash]; const next = current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => if (create) blk: { var created = true; current.createDir(io, component, .default_dir) catch |create_err| switch (create_err) { error.PathAlreadyExists => created = false, else => return error.Storage, }; if (created) if (created_directories) |directories| directories.record(path[0..slash]); break :blk current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; } else return error.PathMissing, else => return error.Storage, }; if (owns_current) current.close(io); current = next; owns_current = true; start = slash + 1; } return .{ .dir = current, .name = path[start..], .owned = owns_current }; } fn directoryContainsOnlyManaged( gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, path: []const u8, current_managed: *const managed.ManagedSet, ) ApplyError!bool { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); var dir = parent.dir.openDir(io, parent.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage; defer dir.close(io); return directoryEntriesAreManaged(gpa, io, dir, path, current_managed); } fn directoryEntriesAreManaged( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, prefix: []const u8, current_managed: *const managed.ManagedSet, ) ApplyError!bool { var iterator = dir.iterate(); while (iterator.next(io) catch return error.Storage) |entry| { const full = joinPath(gpa, prefix, entry.name) catch return error.OutOfMemory; defer gpa.free(full); switch (entry.kind) { .file, .sym_link => if (!current_managed.contains(full)) return false, .directory => { var child = dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage; defer child.close(io); if (!try directoryEntriesAreManaged(gpa, io, child, full, current_managed)) return false; }, else => return false, } } return true; } fn joinPath(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]u8 { const out = try gpa.alloc(u8, prefix.len + 1 + name.len); @memcpy(out[0..prefix.len], prefix); out[prefix.len] = '/'; @memcpy(out[prefix.len + 1 ..], name); return out; } fn createTransaction(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError![]const u8 { var random: [12]u8 = undefined; var encoded: [64]u8 = undefined; var attempt: usize = 0; while (attempt < 32) : (attempt += 1) { io.randomSecure(&random) catch return error.Storage; const suffix = encodeName(&random, &encoded); var name: [41]u8 = undefined; const prefix = ".zit-update-txn-"; @memcpy(name[0..prefix.len], prefix); @memcpy(name[prefix.len .. prefix.len + suffix.len], suffix); // Allocate the caller-owned name before the filesystem side effect: // an ordinary allocation failure must leave no transaction directory. const owned_name = gpa.dupe(u8, name[0 .. prefix.len + suffix.len]) catch return error.OutOfMemory; worktree.createDir(io, owned_name, .default_dir) catch |err| { gpa.free(owned_name); switch (err) { error.PathAlreadyExists => continue, else => return error.Storage, } }; return owned_name; } return error.TransactionUnavailable; } 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); } fn expectSymlinkTarget(io: std.Io, worktree: std.Io.Dir, path: []const u8, expected_target: []const u8) !void { const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false }); try std.testing.expectEqual(.sym_link, stat.kind); var target: [1024]u8 = undefined; const n = try worktree.readLink(io, path, &target); try std.testing.expectEqualStrings(expected_target, target[0..n]); } const RootEntrySet = struct { names: std.ArrayList([]u8) = .empty, fn deinit(self: *RootEntrySet, gpa: std.mem.Allocator) void { for (self.names.items) |name| gpa.free(name); self.names.deinit(gpa); self.* = undefined; } fn contains(self: *const RootEntrySet, name: []const u8) bool { for (self.names.items) |candidate| { if (std.mem.eql(u8, candidate, name)) return true; } return false; } }; fn snapshotRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) !RootEntrySet { var entries = RootEntrySet{}; errdefer entries.deinit(gpa); var root = try worktree.openDir(io, ".", .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); defer root.close(io); var iterator = root.iterate(); while (try iterator.next(io)) |entry| { const name = try gpa.dupe(u8, entry.name); entries.names.append(gpa, name) catch |err| { gpa.free(name); return err; }; } return entries; } fn expectSameRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, expected: *const RootEntrySet) !void { var actual = try snapshotRootEntries(gpa, io, worktree); defer actual.deinit(gpa); try std.testing.expectEqual(expected.names.items.len, actual.names.items.len); for (expected.names.items) |name| try std.testing.expect(actual.contains(name)); } fn setupMutatingFailureFixture( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id_buf: *[64]u8, old_id_buf: *[64]u8, ) ![]const u8 { var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(log, "old executable bytes", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(log, "new regular bytes", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF old-file {s} x\n", .{old_payload_id}); defer gpa.free(old_body); const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf); const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF new-file {s}\n", .{target_payload_id}); defer gpa.free(target_body); const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf); try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(gpa); defer old_managed.deinit(); _ = try old_managed.insert("old-file"); try old_managed.write(io, worktree); try writePayload(io, worktree, "old-file", "old executable bytes", .executable, true, null); return target_id; } fn setupPathTransitionFixture( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, old_path: []const u8, target_path: []const u8, target_id_buf: *[64]u8, old_id_buf: *[64]u8, ) ![]const u8 { var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(log, "old bytes", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(log, "target bytes", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF {s} {s}\n", .{ old_path, old_payload_id }); defer gpa.free(old_body); const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf); const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF {s} {s}\n", .{ target_path, target_payload_id }); defer gpa.free(target_body); const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf); try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(gpa); defer old_managed.deinit(); _ = try old_managed.insert(old_path); try old_managed.write(io, worktree); try writePayload(io, worktree, old_path, "old bytes", .regular, true, null); return target_id; } test "update transaction creation leaves no directory on name allocation failure" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); var failing_allocator = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0 }); try t.expectError(error.OutOfMemory, createTransaction(failing_allocator.allocator(), io, tmp.dir)); try t.expect(failing_allocator.has_induced_failure); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); } test "update rollback removes created ancestors before restoring an old file" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupPathTransitionFixture( t.allocator, io, tmp.dir, &log, "a", "a/b/c", &target_id_buf, &old_id_buf, ); var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "a", "old bytes", .regular); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("a")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id_buf[0..40], state.version); try t.expectEqualStrings("old-branch", state.branch); } test "update replaces managed directory hierarchy with a file" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupPathTransitionFixture( t.allocator, io, tmp.dir, &log, "a/b/c", "a", &target_id_buf, &old_id_buf, ); try applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit); try expectFileState(t.allocator, io, tmp.dir, "a", "target bytes", .regular); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("a")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(target_id, state.version); try t.expectEqualStrings("trunk", state.branch); } test "update restores mutated bytes mode managed set and checkout after pre-commit failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "old-file", "old executable bytes", .executable); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "new-file", .{ .follow_symlinks = false })); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("old-file")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id_buf[0..40], state.version); try t.expectEqualStrings("old-branch", state.branch); } test "update rolls a materialized symlink back after a later failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(&log, "old regular bytes\n", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(&log, "../new-target", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF entry {s}\n", .{old_payload_id}); defer t.allocator.free(old_body); var old_id_buf: [64]u8 = undefined; const old_id = try putCompleteTestArtifact(t.allocator, &log, old_body, &old_id_buf); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF entry {s} l\n", .{target_payload_id}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); try checkout.write(io, tmp.dir, .{ .version = old_id, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(t.allocator); defer old_managed.deinit(); _ = try old_managed.insert("entry"); try old_managed.write(io, tmp.dir); try writePayload(io, tmp.dir, "entry", "old regular bytes\n", .regular, true, null); var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "entry", "old regular bytes\n", .regular); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("entry")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id, state.version); } test "update retains the committed checkout after post-checkout cleanup failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf); test_fault = .before_cleanup; defer test_fault = null; try t.expectError(error.CleanupFailed, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "new-file", "new regular bytes", .regular); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "old-file", .{ .follow_symlinks = false })); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("new-file")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(target_id, state.version); try t.expectEqualStrings("trunk", state.branch); } test "update delta expansion skips unmatched delete markers" { const files = [_]artifact.FCard{ .{ .filename = "gone", .hash = null, .perm = null, .old_name = null, .mode = .regular }, .{ .filename = "kept", .hash = "a" ** 40, .perm = null, .old_name = null, .mode = .regular }, }; var baseline = TestBaseline{ .files = &.{} }; var walk = try artifact.enumerate(.{ .baseline_hash = "b" ** 40, .files = &files }, .{ .ctx = @ptrCast(&baseline), .resolveFn = TestBaseline.resolve, }); var materialized: usize = 0; while (walk.next()) |file| { if (file.hash == null) continue; materialized += 1; try std.testing.expectEqualStrings("kept", file.filename); } try std.testing.expectEqual(@as(usize, 1), materialized); } test "update bootstrap clean state leaves checkout metadata absent" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); var plan = try requireClean(t.allocator, io, tmp.dir, &log, ¤t_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("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); const misnamed_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF other {s}\n", .{"b" ** 40}); defer t.allocator.free(misnamed_body); const misnamed_bytes = try completeTestArtifact(t.allocator, misnamed_body); defer t.allocator.free(misnamed_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "a different artifact", &wrong_name_buf); try putTestArtifact(&log, wrong_name, misnamed_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } test "update state assembly rejects a control stored under another name" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); const control_bytes = try completeTestArtifact(t.allocator, "D 2007-07-21T14:10:00\nT +branch cccccccccccccccccccccccccccccccccccccccc trunk\nU alice\n"); defer t.allocator.free(control_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "a different control artifact", &wrong_name_buf); try putTestArtifact(&log, wrong_name, control_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } // The shape that made `zit update` accuse an intact fossil clone of corruption: // an anonymous wiki page. It is well-framed and its cards parse, but a wiki // artifact requires a `U` card and an anonymous edit has none, so classification // fails with `MissingRequiredCard`. Its bytes still hash to the name it is // stored under, so the repository is undamaged and the artifact is opaque to // state: assembly skips it and completes. // // This is the non-corpus stand-in for the 67,499-artifact fossil clone that // surfaced the defect, where the live instance is artifact // 14dc8dab9792f8f0139dc05d49cd0f5cdc55503f, a 2008 edit of the page // "RockShoxBugs". Nothing here depends on that corpus or on any wall clock — // the trigger is a card set, so a generator can emit it. test "update state assembly skips a stored artifact it cannot classify" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); // D, L, W, Z — no U card, exactly as fossil stores an anonymous wiki edit. var wiki_id_buf: [64]u8 = undefined; _ = try putCompleteTestArtifact( t.allocator, &log, "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n", &wiki_id_buf, ); var assembly = try assembleState(t.allocator, &log, target_id); defer assembly.deinit(); // Skipped, not absorbed: the wiki artifact contributed no state. Assembly // resolved the target it was asked for, and the unclassifiable artifact is // not among the crosslinked records. try t.expect(assembly.state.store.lookup(wiki_id_buf[0..40]) == null); } // The other half of the rule, and the reason the arm verifies before it skips. // Tolerating an artifact this reader declines must not become tolerating one // whose bytes do not match the name they are stored under. Same wiki card set, // same classification failure — but stored under a name that is not its hash. // That is an integrity failure, and it stays fatal. // // Without this test the skip above is vacuous: a blanket `continue` on every // classification failure would pass it and lose the corruption signal entirely. test "update state assembly still rejects an unclassifiable artifact whose name does not verify" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); const wiki_bytes = try completeTestArtifact( t.allocator, "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n", ); defer t.allocator.free(wiki_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "not these bytes", &wrong_name_buf); try putTestArtifact(&log, wrong_name, wiki_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } test "update state assembly retains the stable MemStore backing RepoState" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); var assembly = try assembleState(t.allocator, &log, target_id); defer assembly.deinit(); try t.expectEqual(@intFromPtr(assembly.mem), @intFromPtr(assembly.state.store.context)); const added = try assembly.state.crosslink(.{ .hash = "b" ** 40, .mtime = 1, }); try t.expectEqual(added, assembly.mem.store().lookup("b" ** 40).?); } fn setupPreMergeWorktree(io: std.Io, worktree: std.Io.Dir) !void { // A clean checkout with no pending merge, plus two managed files. try checkout.write(io, worktree, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); try writePayload(io, worktree, "keep-me", "current\n", .regular, true, null); try writePayload(io, worktree, "remove-me", "gone\n", .regular, true, null); } test "merge applyMerge materializes writes and deletes and records the merge parent" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); const other = "2" ** 64; const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } }, .{ .path = "remove-me", .action = .delete }, }; try applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = other, .special_op = "", .manifest_flags = "" }); try expectFileState(t.allocator, io, tmp.dir, "keep-me", "merged\n", .regular); // B-merge-23: the current-side "x" attribute is preserved on the merged file. try expectFileState(t.allocator, io, tmp.dir, "tool.sh", "#!/bin/sh\n", .executable); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "remove-me", .{ .follow_symlinks = false })); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("trunk", ck.branch); try t.expectEqualStrings(other, ck.merge_parent); // Journal armed before mutating: the undo level is present (B-merge-3). _ = try tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false }); } test "merge applyMerge journals the pre-merge image so undo clears the pending merge" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } }, .{ .path = "remove-me", .action = .delete }, }; try applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "2" ** 64, .special_op = "", .manifest_flags = "" }); // The journal captured the pre-merge tree (files + merge_parent == ""), so // undo restores it byte-for-byte and the pending merge disappears (B-merge-13). try journal.undo(t.allocator, io, tmp.dir); try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular); try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "tool.sh", .{ .follow_symlinks = false })); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("", ck.merge_parent); } test "merge applyMerge rolls files and checkout back on an injected mid-apply failure" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); // Entry 0 (write) is applied; the fault fires before entry 1 (delete), so the // tree is partially mutated and rollback must fully restore it. const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "remove-me", .action = .delete }, }; test_fault = .merge_mid_apply; defer test_fault = null; try t.expectError(error.Storage, applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "2" ** 64, .special_op = "", .manifest_flags = "" })); // Files restored, no delete applied, checkout back to no-pending-merge. try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular); try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("", ck.merge_parent); // A clean rollback drops the journal — no spurious undo level survives. try t.expectError(error.FileNotFound, tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false })); } // Pull the undo/redo journal's inline unit tests (contracts/undo.md) into // `zig build test`. `undo.zig` and this file import each other; referencing it // here in the same module keeps a single instance and no duplicate analysis. test { _ = journal; }