//! Transactional working-tree materialization for `zit update CHECKIN`.
//!
//! This layer is deliberately the sole owner of update's filesystem effects:
//! it validates and expands the requested manifest, stages every payload under
//! an exclusive worktree-local transaction directory, proves the current tree
//! clean, then writes `.zit-managed` before atomically committing
//! `.zit-checkout`.
const std = @import("std");
const repeat = @import("repeat.zig").repeat;
const rfc_vectors = @import("rfc_vectors.zig");
const artifact = @import("artifact.zig");
const checkout = @import("checkout.zig");
const logstore = @import("logstore.zig");
const crc32 = @import("crc32.zig");
const managed = @import("managed.zig");
const repository = @import("repository.zig");
const repocache = @import("repocache.zig");
const repostate = @import("repostate.zig");
const control_paths = @import("control_paths.zig");
const cone_mod = @import("cone.zig");
const settings = @import("settings.zig");
const ignore = @import("ignore.zig");
/// The single-level undo/redo journal (contracts/undo.md). Re-exported so the
/// CLI reaches one instance — the same one this module writes through the
/// `record` seam below (mutual import; Zig resolves it, no comptime cycle).
pub const journal = @import("undo.zig");
/// How one materialization decides which working-tree manifest files to write.
/// Fossil derives this from a repository setting; zit does NOT read settings —
/// the opt-in is explicit and per checkout, so what a checkout promises is
/// exactly what the user asked it for. `open --manifest` is the only verb that
/// `set`s it; every other materialization `inherit`s what the checkout record
/// already carries, which is what makes the files stay refreshed.
pub const ManifestOpt = union(enum) {
/// Use the checkout's own remembered selection.
inherit,
/// Use exactly these selectors, and remember them.
set: []const u8,
/// Use the checkout's remembered selection, falling back to the
/// PROJECT's `manifest` setting when it has none.
///
/// fossil makes `manifest` a project setting so a clone gets build stamps
/// without every developer opting in by hand; zit had it only as a
/// per-checkout flag. Passed in rather than read here because resolving a
/// setting spans the config frame, a tracked file and the operator's global
/// file — none of which this module should know about. The caller resolves;
/// this module only applies.
inherit_or: []const u8,
};
/// The working-tree file each `checkout.manifest_selectors` character names,
/// in the same order.
///
/// Public because the Git importer's mirror classifier reads the same list:
/// the set of root files it may drop from an imported Fossil→Git mirror has
/// to be exactly the set this module writes, and two copies of the list could
/// drift into disagreeing about which names are metadata.
pub const manifest_paths = [_][]const u8{ "manifest", "manifest.uuid", "manifest.tags" };
/// The line fossil appends to the on-disk `manifest` so the file cannot be
/// mistaken for — or re-ingested as — the artifact it was copied from.
///
/// Public for the same reason as `manifest_paths`: the importer's classifier
/// undoes exactly this transformation, and it must undo the bytes this writer
/// produces rather than a second literal that merely looks the same.
pub const sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n";
/// The reason text every verb uses for `ApplyError.StateRecordInvalid`. Shared
/// so the update, open and merge diagnostics cannot drift into disagreeing
/// about what the error means — and, in particular, so none of them can
/// silently collapse back into the `Corrupt` "repository is corrupt" wording.
/// The two errors are different claims: `Corrupt` means stored bytes do not
/// match the name they are stored under, while this one means a stored
/// artifact was not usable as a check-in state record, which says nothing
/// about the repository's integrity.
pub const state_record_invalid_reason = "a stored artifact is not a usable check-in state record";
/// Errors intentionally describe the caller-visible update decision rather
/// than leaking individual `std.Io` platform errors.
pub const ApplyError = error{
/// The checkout carries a sparse cone and the operation would apply
/// changes it cannot represent yet -- sparse-cone RFC section 4.4's
/// record-level application is not implemented in this wave, and
/// proceeding would silently lose the incoming out-of-cone edits at the
/// next commit (they end up in neither the baseline nor the worktree).
/// Refusal is the fail-closed interim; the diagnostic names the class.
SparseMergeUnsupported,
/// A file the check-in names has no artifact in this repository, or the
/// artifact it names does not hash to the name. Distinct from `Corrupt`
/// because it is a different claim: the repository read fine and the
/// check-in parsed fine — one file's CONTENT is absent or wrong, and the
/// path and hash below say which. `verify` cannot surface it, because it
/// checks that stored artifacts are valid, never that referenced ones exist.
FileContentMissing,
InvalidCheckinId,
CheckinNotFound,
CheckinContentMissing,
/// §6.3: the target's R aggregate content checksum disagrees with the
/// content its own F run names — the check-in was authored inconsistently
/// (or the store's blobs are not what the manifest attests). Fail-closed
/// exactly as Fossil's default repo-cksum checkout does; materializing a
/// tree the check-in itself disowns helps no one.
AggregateChecksumMismatch,
CheckinNameMismatch,
TargetNotManifest,
BaselineNotFound,
BaselineContentMissing,
BaselineNameMismatch,
BaselineNotManifest,
/// A stored artifact could not be turned into a check-in state record.
/// This is a statement about one artifact's shape, not about repository
/// integrity — see `Corrupt` for that.
StateRecordInvalid,
InvalidPath,
ReservedPath,
SymlinkUnsupported,
CurrentStateInvalid,
WorktreeNotClean,
UnmanagedCollision,
Storage,
Corrupt,
OutOfMemory,
TransactionUnavailable,
RollbackFailed,
CleanupFailed,
};
const ParentError = ApplyError || error{PathMissing};
const TestFault = enum {
after_materialize,
before_cleanup,
merge_mid_apply,
};
// Tests use these boundaries to deterministically exercise the transactional
// branches that ordinary filesystem failures cannot reliably reach. Production
// builds always return false and therefore retain their normal behavior.
var test_fault: ?TestFault = null;
fn shouldInjectTestFault(point: TestFault) bool {
if (comptime @import("builtin").is_test) return test_fault == point;
return false;
}
const PlanEntry = struct {
path: []const u8,
name: []const u8,
digest: [logstore.max_hash_len]u8,
digest_len: u8,
mode: artifact.FileMode,
/// This path is byte-identical (same content digest, same mode) in the
/// BASELINE plan, and `requireClean` has already proven the file on disk
/// matches the baseline -- so the correct on-disk state for it under the
/// target is the file that is already there. Every mutation-phase loop
/// skips it: it is not staged in either direction, not journaled, not
/// deleted, not rewritten. Set by `markUnchanged` on `update`'s plans;
/// never set on `open`'s (whose baseline is empty) or on any other path,
/// so their behavior is exactly what it was.
unchanged: bool = false,
/// Bytes to materialize for this path INSTEAD of the check-in content
/// `digest` names — the working tree's own version, carried across a
/// materialization that would otherwise overwrite it (`CarrySet`). Borrowed
/// from the caller's arena, which outlives the call. Null on every plan
/// built for a clean tree, so nothing about those changes.
///
/// A carried entry is a deliberate disagreement between the plan's digest
/// and the bytes that land on disk, which is why `stagePlan` writes these
/// verbatim (no `artifact.verify`, there being nothing to verify against)
/// and why `VStat.writeForPlan` must skip the entry: a sidecar row pairing
/// this path's stat with the check-in's digest would assert the file is
/// unmodified, and the next `scanTree` would believe it.
carry: ?[]const u8 = null,
fn deinit(self: *PlanEntry, gpa: std.mem.Allocator) void {
gpa.free(self.path);
gpa.free(self.name);
self.* = undefined;
}
fn digestSlice(self: *const PlanEntry) []const u8 {
return self.digest[0..self.digest_len];
}
};
const Plan = struct {
entries: std.ArrayList(PlanEntry) = .empty,
/// The target manifest's R aggregate checksum (32 lowercase hex), when it
/// carries one — held so materialization can verify the plan's content
/// against the check-in's own attestation (§6.3). Null for a manifest
/// without R (legal: Fossil's repo-cksum can be off).
r_checksum: ?[32]u8 = null,
fn deinit(self: *Plan, gpa: std.mem.Allocator) void {
for (self.entries.items) |*entry| entry.deinit(gpa);
self.entries.deinit(gpa);
self.* = undefined;
}
/// Binary search for `path`, exploiting the ascending-byte-order invariant
/// `validateTopology` establishes. Returns the index when present, and
/// otherwise the index the path would be inserted at.
fn locate(self: *const Plan, path: []const u8) struct { idx: usize, found: bool } {
var lo: usize = 0;
var hi: usize = self.entries.items.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
switch (std.mem.order(u8, self.entries.items[mid].path, path)) {
.lt => lo = mid + 1,
.gt => hi = mid,
.eq => return .{ .idx = mid, .found = true },
}
}
return .{ .idx = lo, .found = false };
}
/// Make `path` materialize `content` verbatim, inserting the entry when the
/// plan does not already have it. A carried entry keeps no meaningful
/// digest: its bytes are the working tree's and name no artifact, so the
/// digest is zeroed rather than left describing content that will not be
/// written. `stagePlan` never looks it up (it writes `carry` directly) and
/// `VStat.writeForPlan` abandons the sidecar entirely for such a plan, so
/// there is no reader left to mislead.
fn setCarried(self: *Plan, gpa: std.mem.Allocator, path: []const u8, content: Carry.Content) ApplyError!void {
const at = self.locate(path);
if (at.found) {
const entry = &self.entries.items[at.idx];
entry.carry = content.bytes;
entry.mode = content.mode;
entry.unchanged = false;
return;
}
const owned_path = gpa.dupe(u8, path) catch return error.OutOfMemory;
errdefer gpa.free(owned_path);
const owned_name = gpa.dupe(u8, "") catch return error.OutOfMemory;
errdefer gpa.free(owned_name);
self.entries.insert(gpa, at.idx, .{
.path = owned_path,
.name = owned_name,
.digest = std.mem.zeroes([logstore.max_hash_len]u8),
.digest_len = 0,
.mode = content.mode,
.carry = content.bytes,
}) catch return error.OutOfMemory;
}
/// Drop `path` from the plan if it is there. Absent is success: a carry may
/// name a path neither check-in has (locally added, then locally deleted).
fn removePath(self: *Plan, gpa: std.mem.Allocator, path: []const u8) void {
const at = self.locate(path);
if (!at.found) return;
var entry = self.entries.orderedRemove(at.idx);
entry.deinit(gpa);
}
fn add(self: *Plan, gpa: std.mem.Allocator, file: artifact.FCard) ApplyError!void {
const name = file.hash orelse return;
try validateWorktreePath(file.filename);
var digest: [logstore.max_hash_len]u8 = undefined;
const decoded = try decodeValidatedName(name, &digest);
const path = gpa.dupe(u8, file.filename) catch return error.OutOfMemory;
errdefer gpa.free(path);
const owned_name = gpa.dupe(u8, name) catch return error.OutOfMemory;
errdefer gpa.free(owned_name);
self.entries.append(gpa, .{
.path = path,
.name = owned_name,
.digest = digest,
.digest_len = @intCast(decoded.len),
.mode = file.mode,
}) catch return error.OutOfMemory;
}
fn validateTopology(self: *const Plan) ApplyError!void {
if (self.entries.items.len < 2) return;
for (self.entries.items[1..], self.entries.items[0 .. self.entries.items.len - 1]) |entry, previous| {
if (std.mem.eql(u8, entry.path, previous.path)) return error.InvalidPath;
if (isPathPrefix(previous.path, entry.path)) return error.InvalidPath;
}
}
};
const LoadedManifest = struct {
bytes: []u8,
manifest: artifact.Manifest,
/// Non-null when `parseForImport` normalized a legacy trailing-separator F
/// card: the manifest borrows this copy, not `bytes` (read-path only).
normalized: ?[]u8 = null,
fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void {
self.manifest.deinit();
if (self.normalized) |b| gpa.free(b);
gpa.free(self.bytes);
self.* = undefined;
}
};
const BaselineContext = struct {
expected_hash: ?[]const u8 = null,
manifest: ?*const artifact.Manifest = null,
};
/// The derived-state result of `assembleState`. Published (with
/// `assembleState`) as the DST-facing entry into update's log→state
/// derivation, so `src/sim/assembly_sweep.zig` can drive the §4
/// unclassifiable-artifact rule against an independent oracle without going
/// through the worktree-mutating `applyCheckin` path.
pub const StateAssembly = struct {
gpa: std.mem.Allocator,
mem: *repostate.MemStore,
state: repostate.RepoState,
target_rid: repostate.Rid,
pub fn deinit(self: *StateAssembly) void {
self.state.deinit();
self.mem.deinit();
self.gpa.destroy(self.mem);
self.* = undefined;
}
};
const OpenedParent = struct {
dir: std.Io.Dir,
name: []const u8,
owned: bool,
fn close(self: OpenedParent, io: std.Io) void {
if (self.owned) self.dir.close(io);
}
};
/// The last parent directory a plan walk opened, kept open across entries.
/// A plan is path-sorted, so consecutive entries overwhelmingly share a parent;
/// without this every entry re-walked its directory chain component by
/// component -- one openat+close per level, several times per file -- which
/// was the single largest cost of materializing a tree (~31% of wall).
/// `prefix` borrows the plan entry's own path bytes, which outlive the walk.
const ParentCache = struct {
dir: ?std.Io.Dir = null,
prefix: []const u8 = "",
fn deinit(self: *ParentCache, io: std.Io) void {
if (self.dir) |*d| d.close(io);
self.dir = null;
self.prefix = "";
}
};
/// `openParentNoFollow` through a `ParentCache`: a hit hands back the cached
/// handle (never owned by the caller), a miss walks, then caches the result.
fn openParentCached(
cache: *ParentCache,
io: std.Io,
root: std.Io.Dir,
path: []const u8,
create: bool,
created_directories: ?*CreatedDirectories,
) ParentError!OpenedParent {
const slash = std.mem.lastIndexOfScalar(u8, path, '/');
const prefix = if (slash) |i| path[0..i] else "";
const name = if (slash) |i| path[i + 1 ..] else path;
if (cache.dir) |d| {
if (std.mem.eql(u8, cache.prefix, prefix)) return .{ .dir = d, .name = name, .owned = false };
}
const opened = try openParentNoFollow(io, root, path, create, created_directories);
cache.deinit(io);
if (opened.owned) {
cache.dir = opened.dir;
cache.prefix = prefix;
}
return .{ .dir = opened.dir, .name = opened.name, .owned = false };
}
const ExistingPath = union(enum) {
missing,
stat: std.Io.File.Stat,
};
/// Directories made while materializing the target are held as borrowed
/// slices into the already-owned plan. Capacity is reserved before any
/// worktree mutation so recording a successful `createDir` cannot fail.
const CreatedDirectories = struct {
paths: std.ArrayList([]const u8) = .empty,
fn init(gpa: std.mem.Allocator, plan: *const Plan) ApplyError!CreatedDirectories {
var capacity: usize = 0;
for (plan.entries.items) |entry| {
for (entry.path) |byte| {
if (byte == '/') capacity += 1;
}
}
var result = CreatedDirectories{};
result.paths.ensureTotalCapacity(gpa, capacity) catch return error.OutOfMemory;
return result;
}
fn deinit(self: *CreatedDirectories, gpa: std.mem.Allocator) void {
self.paths.deinit(gpa);
self.* = undefined;
}
fn record(self: *CreatedDirectories, path: []const u8) void {
self.paths.appendAssumeCapacity(path);
}
fn remove(self: *const CreatedDirectories, io: std.Io, worktree: std.Io.Dir) ApplyError!void {
var index = self.paths.items.len;
while (index > 0) {
index -= 1;
const path = self.paths.items[index];
const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
error.PathMissing => continue,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.FileNotFound => {},
error.DirNotEmpty => return error.Storage,
else => return error.Storage,
};
}
}
};
/// Materialize canonical `target_id` into `worktree`.
///
/// `target_id` is a public canonical artifact name. It is decoded to a raw
/// digest for every `LogStore.lookup`; textual names are never used as log
/// index keys.
/// The checkout's stored sparse cone (container section 5), or null when
/// dense. A present-but-unparseable cone is authoritative state that fails
/// closed as Corrupt -- never silently treated as dense, which would make a
/// damaged byte quietly rematerialize an 80k-file tree.
pub fn readStoredCone(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError!?cone_mod.Cone {
const payload = checkout.readSectionAlloc(gpa, io, worktree, .cone) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => return error.Storage,
error.Corrupt => return error.Corrupt,
};
const bytes = payload orelse return null;
defer gpa.free(bytes);
const parsed = cone_mod.Cone.parse(gpa, bytes) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidMember, error.ReservedMember, error.Bounds => return error.Corrupt,
error.DeadExclusion, error.Contradiction => return error.Corrupt,
};
return parsed;
}
/// The cone this materialization applies -- the `ManifestOpt` pattern.
/// `.inherit` uses the checkout's stored cone (dense when none); `.set`
/// replaces it (null payload = return to dense). The record and cone commit
/// in the same container splice as the managed set.
pub const SparseOpt = union(enum) {
inherit,
set: ?*const cone_mod.Cone,
};
/// One path the working tree keeps across a materialization that would
/// otherwise overwrite it with the target's version.
///
/// Both endpoints are named because both plans are staged: `after` is what must
/// be on disk when the update commits — for a dirty `update`, the three-way
/// merge of (current check-in, working file, target) — and `before` is what is
/// on disk right now, which is what a failed update has to put back. Supplying
/// only `after` would leave the rollback image holding the CHECK-IN's content
/// for these paths, so an update that failed halfway would restore committed
/// bytes over the operator's edits: a rollback that loses work.
///
/// Either side may be null, meaning the path does not exist at that endpoint —
/// `after == null` is how a local delete survives a target that still carries
/// the file, and `before == null` is a path the merge adds.
///
/// A carry always wins over the target plan, because it was computed FROM the
/// target: the merge already decided what the target's contribution to this
/// path is, so re-applying the plan's own content on top would undo it.
pub const Carry = struct {
path: []const u8,
after: ?Content,
before: ?Content,
pub const Content = struct {
bytes: []const u8,
mode: artifact.FileMode = .regular,
};
};
/// Create the directories the `empty-dirs` setting names, so
/// a project can version a tree shape that carries no files. Fossil does it
/// from `ensure_empty_dirs_created` (update.c, checkout.c) after a checkout or
/// an update; zit calls it from the same three verbs.
///
/// Each pattern is a LITERAL relative directory name, not a glob — fossil
/// splits the value with the glob tokenizer but then uses each pattern as a
/// path, so `PatternIter` supplies the split and nothing matches anything.
/// Missing parents are created with it.
///
/// Three deliberate rules:
/// * a pattern the worktree-path rules reject is warned about and skipped.
/// Fossil does not check, but this value can arrive from a VERSIONED file,
/// and a check-in must not be able to mint `.zit` or escape the tree.
/// * a name already held by a non-directory gets one warning and is left
/// alone; nothing here ever deletes or replaces.
/// * a creation failure is a warning, not an error. An empty directory is a
/// convenience, and losing one must never cost the operator the
/// materialization that just succeeded — so `ApplyError` does not widen.
pub fn ensureEmptyDirs(io: std.Io, worktree: std.Io.Dir, patterns: []const u8, warn: *std.Io.Writer) void {
var it: ignore.PatternIter = .init(patterns);
// Bound: `PatternIter.next` advances at least one byte per pattern, so the
// loop runs at most `patterns.len` times.
var seen: usize = 0;
while (it.next()) |dir| : (seen += 1) {
std.debug.assert(seen <= patterns.len);
std.debug.assert(dir.len != 0);
control_paths.validateWorktreePath(dir) catch {
warn.print("zit: empty-dirs: '{s}' is not a directory this checkout may create\n", .{dir}) catch {};
continue;
};
if (worktree.statFile(io, dir, .{ .follow_symlinks = false })) |st| {
if (st.kind == .directory) continue;
warn.print("zit: empty-dirs: '{s}' exists and is not a directory\n", .{dir}) catch {};
continue;
} else |err| switch (err) {
error.FileNotFound, error.NotDir => {},
else => {
warn.print("zit: empty-dirs: cannot inspect '{s}'\n", .{dir}) catch {};
continue;
},
}
worktree.createDirPath(io, dir) catch {
warn.print("zit: empty-dirs: cannot create directory '{s}'\n", .{dir}) catch {};
};
}
}
pub fn applyCheckin(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
target_id: []const u8,
manifest: ManifestOpt,
sparse: SparseOpt,
) ApplyError!void {
return applyCheckinWith(gpa, io, worktree, log, target_id, manifest, sparse, null, null);
}
/// `applyCheckin` with the caller's already-open `Repository`, when it has
/// one. The target's branch and tags are read from it instead of opening a
/// second repository for the lookup -- a full repo-state derive, which was a
/// fifth of `zit open`'s wall time on a 146k-artifact store. A caller
/// without one (the unit tests, the post-fetch retry) passes null and pays
/// the derive.
pub fn applyCheckinWith(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
target_id: []const u8,
manifest: ManifestOpt,
sparse: SparseOpt,
repo: ?*repository.Repository,
/// How to keep working-tree content across the move. Null is the
/// plain materialization every caller but `update` wants: the tree must be
/// clean or the update refuses, exactly as before this parameter existed.
carry_resolver: ?CarryResolver,
) ApplyError!void {
// Resolve the cone this materialization runs under. Read before any plan
// is built; the stored cone (when inheriting) must outlive both plans.
var inherited_cone: ?cone_mod.Cone = null;
defer if (inherited_cone) |*c| c.deinit(gpa);
const effective_cone: ?*const cone_mod.Cone = switch (sparse) {
.set => |c| c,
.inherit => blk: {
inherited_cone = try readStoredCone(gpa, io, worktree);
break :blk if (inherited_cone) |*c| c else null;
},
};
// The section update the commit splice carries: only `.set` changes the
// stored cone; `.inherit` carries it forward untouched (null here).
var cone_payload: ?[]u8 = null;
defer if (cone_payload) |cp| gpa.free(cp);
const cone_section_update: ?checkout.SectionUpdate = switch (sparse) {
.inherit => null,
.set => |c| blk: {
if (c) |real| {
cone_payload = real.serialize(gpa) catch return error.OutOfMemory;
break :blk .{ .tag = .cone, .payload = cone_payload.? };
}
break :blk .{ .tag = .cone, .payload = null }; // return to dense
},
};
var target = try buildPlan(gpa, log, target_id, effective_cone);
defer target.deinit(gpa);
if (effective_cone == null) {
try verifyPlanAggregate(gpa, log, &target);
} else {
// Sparse-cone RFC section 4.3 (verification side): a present R card
// is a WHOLE-TREE aggregate, so a coned plan can never satisfy it.
// The aggregate pass reads blobs from the LOG, not the worktree, and
// the store is dense in Wave 1 -- so verify over a dense plan built
// for exactly this check. The cone narrows materialization, never R.
var dense_probe = try buildPlan(gpa, log, target_id, null);
defer dense_probe.deinit(gpa);
try verifyPlanAggregate(gpa, log, &dense_probe);
}
// The effective manifest opt-in for this materialization AND for the record
// it commits. Read before anything mutates, and held in a function-scoped
// buffer the borrowed slice outlives.
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const manifest_raw = switch (manifest) {
// An explicit choice of NONE is remembered as the
// sentinel, so the project setting cannot switch stamps back on at the
// next update; "" stays the never-chosen state.
.set => |f| if (settings.manifestFlags(f).len == 0) checkout.manifest_off else f,
.inherit => blk: {
const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);
break :blk if (cur) |c| c.manifest_flags else "";
},
.inherit_or => |project| blk: {
const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);
const own = if (cur) |c| c.manifest_flags else "";
// The checkout's own choice wins: an operator who ran
// `open --manifest=off` to turn stamps off must not have the
// project setting turn them back on at the next update.
break :blk if (own.len != 0) own else project;
},
};
// What the record below stores: the sentinel verbatim, anything else as
// the normalized letters, so a stale spelling can never be re-stored.
const manifest_stored = if (std.mem.eql(u8, manifest_raw, checkout.manifest_off)) checkout.manifest_off else settings.manifestFlags(manifest_raw);
// THE manifest normalizer, applied once: fossil accepts `on`/`1`/`true`/
// `yes` (and their false-ish opposites) wherever it accepts the selector
// letters (`db.c db_get_manifest_setting`). Wrapping the switch RESULT
// covers open's `.set`, update's `.inherit_or`, bisect's `.inherit`, the
// project setting, and the record stored below, with no call-site edits.
// Idempotent, so re-inheriting already-normalized letters is a no-op.
const manifest_flags = checkout.manifestSelectors(manifest_stored);
var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer old_managed.deinit();
// The ONE scan of the working tree. Without a resolver it is the historical
// refusing scan; with one it collects instead, and the resolver decides what
// to do about each divergence. A clean tree yields no divergences and so
// never reaches the resolver, which is why carrying costs a clean update
// nothing at all.
var divergences: std.ArrayList(Divergence) = .empty;
defer divergences.deinit(gpa);
var old = if (carry_resolver == null)
try requireClean(gpa, io, worktree, log, &old_managed)
else
try scanTree(gpa, io, worktree, log, &old_managed, &divergences);
defer old.deinit(gpa);
// The divergence paths borrow `old` and `old_managed`, both of which
// outlive this call. `Refused` reads as WorktreeNotClean because that is
// precisely what it means — the tree diverges in a way this move cannot
// reconcile — and the resolver has already named the paths.
const carry: []const Carry = if (divergences.items.len == 0) &.{} else blk: {
const r = carry_resolver.?;
break :blk r.resolve(r.ctx, divergences.items) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Refused => return error.WorktreeNotClean,
};
};
const want_tags = std.mem.indexOfScalar(u8, manifest_flags, 't') != null;
var facts = cachedTargetFacts(gpa, io, worktree, target_id, want_tags, repo) orelse
try deriveTargetFacts(gpa, log, target_id, want_tags);
defer facts.deinit(gpa);
const branch = facts.branch;
// Bulk-built from the plan's own (sorted, unique) order: the one-at-a-time
// `insert` loop this replaces was quadratic and measured at 23.9s of a
// 68k-file update -- larger than every real phase of the verb combined.
const plan_paths = gpa.alloc([]const u8, target.entries.items.len) catch return error.OutOfMemory;
defer gpa.free(plan_paths);
for (target.entries.items, plan_paths) |entry, *slot| slot.* = entry.path;
var desired_managed = managed.ManagedSet.fromSortedPaths(gpa, plan_paths) catch |err| return mapManagedError(err);
defer desired_managed.deinit();
// The managed set this update will commit is EXACTLY the target plan's
// membership: one entry per plan path. `validateTopology` proved those paths
// unique (and non-nesting), so every dedup insert added a distinct path —
// the two can never disagree in count, the first half of the managed-set /
// record / tree agreement the commit below depends on.
std.debug.assert(desired_managed.iterate().len == target.entries.items.len);
// Both plans are final from here on, so the unchanged marks computed now
// hold for every phase below.
try markUnchanged(gpa, &target, &old);
// Overlay the carries LAST, so nothing above can be computed from them:
// `verifyPlanAggregate` has already checked the target's R card against the
// check-in's own content (a carried byte is not the check-in's and has no
// place in its attestation), `desired_managed` is the target's membership,
// and `markUnchanged` has compared the two plans as the store describes
// them. Overlaying clears `unchanged` on every path it touches, so a
// carried path is always written even when the two check-ins agree about it.
if (carry.len != 0) {
try overlayCarry(gpa, &target, &old, carry, &desired_managed);
// The managed set must still describe exactly what the target plan
// materializes, or the commit records a membership the tree does not
// have. `overlayCarry` maintains both together for this reason.
std.debug.assert(desired_managed.iterate().len == target.entries.items.len);
}
const txn_name = try createTransaction(gpa, io, worktree);
defer gpa.free(txn_name);
var transaction_live = true;
defer if (transaction_live) worktree.deleteTree(io, txn_name) catch {};
var txn = worktree.openDir(io, txn_name, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer txn.close(io);
txn.createDir(io, "next", .default_dir) catch return error.Storage;
txn.createDir(io, "previous", .default_dir) catch return error.Storage;
var next = txn.openDir(io, "next", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer next.close(io);
var previous = txn.openDir(io, "previous", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer previous.close(io);
// No worktree data is touched until both the desired and rollback payloads
// are verified and staged in this same-filesystem transaction dir, from
// which the commit renames them into place. Not fsync'd: the tree's
// crash image is the undo journal's to guarantee, not the stage's.
try stagePlan(gpa, io, log, next, &target);
try stagePlan(gpa, io, log, previous, &old);
try preflight(gpa, io, worktree, &target, &old_managed);
var created_directories = try CreatedDirectories.init(gpa, &target);
defer created_directories.deinit(gpa);
// Journal the undo image (B-undo-3/14): the pre-mutation bytes of every path
// this update writes or deletes — `old_managed ∪ target` — plus the current
// `.zit-checkout`/`.zit-managed`. Written and fsynced HERE, before
// `commitMaterialization` moves the first working-file byte. Placed after
// `requireClean`/`preflight`, so a rejected update never journals; the
// rollback branch below clears it so a cleanly-undone update leaves no level.
try recordUndoImage(gpa, io, worktree, &old, &target);
commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{
.version = target_id,
.branch = branch,
// `update` never carries a pending merge (that is `merge`'s job); a
// plain check-out always clears it — and never a pending special merge.
.merge_parent = "",
.special_op = "",
.manifest_flags = manifest_stored,
}, .{ .log = log, .target_id = target_id, .tags = facts.tags }, cone_section_update) catch |err| {
rollbackMaterialization(gpa, io, worktree, previous, &target, &old, &old_managed, &created_directories) catch {
// The staged `previous/` tree is the only complete recovery copy.
// Retain it for recovery rather than letting the deferred cleanup
// destroy it after a failed inverse. The undo journal is retained
// too: it is the crash-recovery image for the half-applied tree.
transaction_live = false;
return error.RollbackFailed;
};
// Clean rollback: the tree is back to pre-mutation, so there is nothing
// to undo — drop the journal (keeps B-undo-9 honest).
journal.clear(io, worktree);
return err;
};
// Sparse-cone RFC section 5.4: on a store that is ALREADY sparse, a
// `.set` cone request narrows (or widens) store policy alongside the
// worktree -- one flag, one intent. On a dense store the request stays
// worktree-only: creating store policy here would silently make
// `pack --drop-elided` eligible to reclaim, an escalation only the
// fetch paths (which the operator sees) and clone --cone may perform.
// Best-effort and post-commit-point, like the vstat sidecar below.
if (sparse == .set) {
var existing = repocache.readStoreCone(gpa, io, worktree);
if (existing) |*c| {
c.deinit(gpa);
repocache.writeStoreCone(gpa, io, worktree, switch (sparse) {
.set => |req| req,
.inherit => unreachable,
});
}
}
// The tree and record are committed; remember what was just written so
// the next clean check can stat instead of reconstruct. After the commit
// point deliberately: a sidecar describing a tree that failed to commit
// would be a lie, while a missing sidecar is only slowness.
VStat.writeForPlan(gpa, io, worktree, &target, target_id);
// `.zit-checkout` is the commit point. A post-commit cleanup error is
// reported but must never roll the committed tree or metadata back.
transaction_live = false;
if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed;
worktree.deleteTree(io, txn_name) catch return error.CleanupFailed;
}
/// Build the affected-path set (`old_managed ∪ target`, deduplicated by a
/// throwaway `ManagedSet`) and hand it to the undo journal (B-undo-14: exactly
/// one seam per mutator). The set unifies both directions of the mutation — a
/// path the update writes, a path it deletes — so `zit undo` restores every one.
fn recordUndoImage(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
old: *const Plan,
target: *const Plan,
) ApplyError!void {
var affected = managed.ManagedSet.init(gpa);
defer affected.deinit();
// The OLD PLAN stands in for the old managed set: `requireClean` proved
// their memberships identical before this runs, and the plan carries the
// unchanged marks the set cannot. An unchanged path is neither written
// nor deleted by this update, so B-undo-3's "every path this update
// writes or deletes" excludes it by its own words.
for (old.entries.items) |entry| {
if (entry.unchanged) continue;
_ = affected.insert(entry.path) catch |err| return mapManagedError(err);
}
for (target.entries.items) |entry| {
if (entry.unchanged) continue;
_ = affected.insert(entry.path) catch |err| return mapManagedError(err);
}
// The affected set is the union of both plans' CHANGED entries, journaled
// before the first working byte moves (B-undo-3/14). It can therefore never
// be larger than either plan, and never smaller than the target's changed
// count -- the direction the durability rule actually needs.
std.debug.assert(affected.iterate().len <= old.entries.items.len + target.entries.items.len);
journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
// A journal device fault must abort BEFORE any working file moves: the
// durability rule forbids mutating without a recoverable pre-image.
error.Storage, error.Corrupt, error.Empty => return error.Storage,
};
}
/// Apply the caller's carries to the target plan (what materializes) and the
/// baseline plan (what a failed update restores), keeping `desired_managed` in
/// step with the target's membership.
///
/// Every touched entry loses its `unchanged` mark: that mark means "the file
/// already on disk is correct", which is decided by comparing the two CHECK-INS
/// and is exactly the assumption a carry contradicts.
fn overlayCarry(
gpa: std.mem.Allocator,
target: *Plan,
old: *Plan,
carry: []const Carry,
desired_managed: *managed.ManagedSet,
) ApplyError!void {
for (carry) |item| {
try validateWorktreePath(item.path);
if (item.after) |after| {
try target.setCarried(gpa, item.path, after);
_ = desired_managed.insert(item.path) catch |err| return mapManagedError(err);
} else {
target.removePath(gpa, item.path);
_ = desired_managed.remove(item.path);
}
if (item.before) |before| {
try old.setCarried(gpa, item.path, before);
} else {
old.removePath(gpa, item.path);
}
}
}
fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, sparse: ?*const cone_mod.Cone) ApplyError!Plan {
if (!artifact.validateName(target_id)) return error.InvalidCheckinId;
var target_digest: [logstore.max_hash_len]u8 = undefined;
const raw_target = try decodeValidatedName(target_id, &target_digest);
const target_rid = log.lookup(raw_target) orelse return error.CheckinNotFound;
const target_bytes = log.content(gpa, target_rid) catch |err| return mapContentError(err);
if (target_bytes == null) return error.CheckinContentMissing;
var target = LoadedManifest{
.bytes = target_bytes.?,
.manifest = undefined,
};
{
errdefer gpa.free(target.bytes);
if (!artifact.verify(target_id, target.bytes)) return error.CheckinNameMismatch;
// Read path: verify identity over the original bytes, then tolerate the
// legacy F-card trailing separator when parsing fossil-origin history.
const im = artifact.parseForImport(gpa, target.bytes) catch |err| return mapTargetParseError(err);
target.manifest = im.manifest;
target.normalized = im.normalized;
}
defer target.deinit(gpa);
if (target.manifest.kind != .manifest) return error.TargetNotManifest;
// One memo across BOTH flattens below. Target and baseline share every node
// the check-in did not touch, so the baseline walk answers from here instead
// of re-reading the store. Owned by the plan build: it dies with this call,
// caching nothing across invocations.
var sub_memo: ?repostate.SubManifestMemo = null;
defer if (sub_memo) |*m| m.deinit();
// submanifests §5.1: a tree check-in's file set lives behind its S cards, so
// walk it into a flat card list before §8 ever looks. §4.4 makes this
// exclusive with the B-card branch below (never both B and S), so a check-in
// is walked or resolved, never both.
try flattenIfTree(gpa, log, &target.manifest, &sub_memo);
var baseline: ?LoadedManifest = null;
defer if (baseline) |*item| item.deinit(gpa);
var baseline_context = BaselineContext{};
if (target.manifest.b) |b| {
var digest: [logstore.max_hash_len]u8 = undefined;
const raw = decodeValidatedName(b.hash, &digest) catch return error.BaselineNotFound;
const rid = log.lookup(raw) orelse return error.BaselineNotFound;
const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) return error.BaselineContentMissing;
var item = LoadedManifest{ .bytes = bytes.?, .manifest = undefined };
{
errdefer gpa.free(item.bytes);
if (!artifact.verify(b.hash, item.bytes)) return error.BaselineNameMismatch;
const im = artifact.parseForImport(gpa, item.bytes) catch |err| return mapBaselineParseError(err);
item.manifest = im.manifest;
item.normalized = im.normalized;
}
if (item.manifest.kind != .manifest) {
item.deinit(gpa);
return error.BaselineNotManifest;
}
baseline = item;
// submanifests §5.4: "a delta manifest MAY name a tree manifest in its B
// card ... the baseline cursor of the merge-walk ... is the enumeration
// of Section 5.1." Flattening here makes that composition free.
try flattenIfTree(gpa, log, &baseline.?.manifest, &sub_memo);
baseline_context.expected_hash = b.hash;
baseline_context.manifest = &baseline.?.manifest;
}
const target_files = repository.manifestFilesOf(&target.manifest);
var walk = artifact.enumerate(target_files, .{
.ctx = @ptrCast(&baseline_context),
.resolveFn = resolveBaseline,
}) catch |err| switch (err) {
error.BaselineUnresolvable => return error.BaselineNotFound,
};
// Sparse-cone RFC section 4.1: the cone lands here, at the seam, once --
// every plan consumer inherits the filter and no call site threads it.
walk.sparse = sparse;
var result = Plan{};
errdefer result.deinit(gpa);
if (target.manifest.r) |r| {
std.debug.assert(r.checksum.len == 32); // §4.7.1: the parser admits exactly 32 hex
var r_owned: [32]u8 = undefined;
@memcpy(&r_owned, r.checksum[0..32]);
result.r_checksum = r_owned;
}
while (walk.next()) |file| {
if (file.hash == null) continue;
try result.add(gpa, file);
}
try result.validateTopology();
return result;
}
/// §6.3 the checkout-side R verification, Fossil's default repo-cksum
/// behavior: when the target carries an R card, recompute the aggregate over
/// the PLAN's enumeration from the log's own blobs and refuse a mismatch
/// before anything is materialized. Entries whose F run omitted a hash never
/// enter the plan, matching §6.3.2's enumeration of the effective set. A
/// manifest without R (repo-cksum off at its writer) verifies nothing —
/// absence is legal, disagreement is not. One full content pass over the
/// plan; the price Fossil's default pays per checkout, paid here in the one
/// verb family that materializes a check-in.
fn verifyPlanAggregate(gpa: std.mem.Allocator, log: *logstore.LogStore, plan: *const Plan) ApplyError!void {
const want = plan.r_checksum orelse return;
var agg: artifact.ChecksumAggregator = .{};
for (plan.entries.items) |entry| {
// A blob that cannot be read is not R's finding: the checksum claim is
// "when the content is all present, it must agree". Abstain and let
// the staging pass name the missing/damaged file precisely.
const rid = log.lookup(entry.digestSlice()) orelse return;
const bytes = (log.content(gpa, rid) catch |err| return mapContentError(err)) orelse return;
defer gpa.free(bytes);
agg.update(entry.path, bytes);
}
var got: [32]u8 = undefined;
_ = agg.final(&got);
if (!std.mem.eql(u8, &got, &want)) return error.AggregateChecksumMismatch;
}
/// submanifests §5.1/§5.4: derive `manifest`'s complete file set when it is a
/// tree manifest, leaving a flat or delta manifest untouched. Runs before the §8
/// enumeration below, so `artifact.enumerate` and the merge walk stay unchanged
/// (§5.4: "the merge-walk itself is unchanged").
///
/// The §5.2 resolve-verify-parse walk itself is NOT reimplemented here.
/// `Repository.flattenTreeFromLog` already takes an already-open LogStore —
/// precisely what this module holds — so update reaches the same resolver
/// `commit` writes through, and §5.2 has exactly one implementation in the tree.
/// That matters beyond deduplication: a second copy drifted once already, over
/// the §3.5 rule that an S card's hash is case-insensitive. The shared resolver
/// decodes the token to raw digest bytes and compares those, so an uppercase
/// spelling names the same artifact — which is what §3.5 says it is.
///
/// Every §5.1 fault becomes `BaselineNotFound`, the code this file already uses
/// for §8.3's `BaselineUnresolvable`. §5.2 puts S resolution "under the same two
/// disciplines" as the B card and mandates hard failure without mandating a
/// distinct code; the five-way `TreeError` distinction the conformance vectors
/// test lives at the artifact layer, where it is observable. The store faults
/// stay distinct: the mapping below is the exact inverse of the one
/// `Repository`'s log source applies, so no `ApplyError` consumer loses a
/// distinction it could branch on.
/// `slot` is a caller-owned memo shared by the TARGET and BASELINE flattens of
/// one plan. §5.4 lets a delta manifest name a tree manifest in its B card, so
/// those two walks run over trees that differ only where the check-in changed —
/// which on a wide repository is a handful of paths out of tens of thousands.
/// Without the memo each walk re-inflates and re-parses every shared node from
/// the store, and the second one learns nothing from the first.
///
/// This does NOT make the cost proportional to what changed: both walks still
/// enumerate the whole tree. It removes the redundant PARSE, not the redundant
/// WALK. Pruning the walk needs the lockstep descent `pack.zig` uses, which is
/// a larger change.
fn flattenIfTree(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
manifest: *artifact.Manifest,
slot: *?repostate.SubManifestMemo,
) ApplyError!void {
if (manifest.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one
repository.Repository.flattenTreeFromLogMemo(gpa, log, manifest, slot) catch |err| return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Corrupt => error.Corrupt,
// The log source renames the LogStore's `Storage` on the way out; rename
// it back so a store fault still reads as one here.
error.CannotRead => error.Storage,
// artifact.TreeError, listed rather than caught by `else` so a new §5.1
// fault code has to be triaged here instead of silently collapsing.
error.SubUnresolvable,
error.DepthExceeded,
error.ReferenceCycle,
error.FirstFilenameMismatch,
error.OrderViolation,
=> error.BaselineNotFound, // §5.2 hard failure
};
std.debug.assert(manifest.flattened != null); // §5.2: never a partial file set
}
fn resolveBaseline(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const baseline: *const BaselineContext = @ptrCast(@alignCast(context));
const expected = baseline.expected_hash orelse return null;
const manifest = baseline.manifest orelse return null;
if (!std.mem.eql(u8, expected, hash)) return null;
return repository.manifestFilesOf(manifest);
}
/// How one path's working-tree state differs from the check-in the checkout
/// names. Only divergent paths get one; a clean path is absent from the list.
pub const DivergenceKind = enum {
/// Managed and present in the check-in, but the bytes (or mode) on disk
/// differ from it.
modified,
/// Managed and present in the check-in, but absent from the working tree.
missing,
/// Managed but NOT in the check-in — `zit add` since the materialization.
added,
/// In the check-in but no longer managed — `zit rm` since.
removed,
};
/// One divergent path. `path` borrows the managed set / plan that produced it,
/// both of which die with the scan, so `scanWorktree` dupes onto its own arena.
pub const Divergence = struct {
path: []const u8,
kind: DivergenceKind,
};
/// How a carrying `update` decides what to keep. `applyCheckinWith` scans the
/// working tree ONCE and, only if it finds divergence, hands the whole list
/// here; the answer is the carries to overlay.
///
/// A callback rather than a plain `[]const Carry` parameter because the caller
/// cannot compute carries without knowing the divergences, and it cannot learn
/// the divergences without a scan — so passing them in would mean scanning the
/// tree twice for every update, including the clean ones that never carry
/// anything. Measured on a 20k-file tree, that doubled scan cost a clean no-op
/// update 63% (0.38s -> 0.62s). Inverting the call keeps it at one scan.
///
/// The policy (three-way merging each divergent path) lives with the caller,
/// which has the repository and the merge engine; the scan and the transaction
/// live here. Neither half needs the other's machinery.
pub const CarryResolver = struct {
ctx: *anyopaque,
/// `divergences` is ascending by path and borrows storage that dies when
/// this call returns — copy anything that must outlive it. The returned
/// carries must outlive the whole `applyCheckinWith` call.
///
/// `error.Refused` means "these changes cannot be carried"; the caller has
/// already reported which and why, and the update aborts before mutating
/// anything.
resolve: *const fn (ctx: *anyopaque, divergences: []const Divergence) error{ OutOfMemory, Refused }![]const Carry,
};
/// The divergent paths of the working tree against the check-in it is on,
/// ascending by path, duped onto `arena`. Empty means clean — exactly the
/// condition `requireCleanTree` demands.
///
/// `update` does not use this: it carries content across a materialization it
/// already scans for, so it takes its divergences from that one scan via
/// `CarryResolver`. `merge` does not materialize a check-in — it applies an
/// outcome set through `applyMerge` — so it has no scan of its own to piggyback
/// on and asks here instead.
pub fn scanWorktree(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
) ApplyError![]const Divergence {
var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer current_managed.deinit();
var found: std.ArrayList(Divergence) = .empty;
defer found.deinit(gpa);
// The recorded paths BORROW the plan and the managed set, so they are duped
// onto the arena before either dies — not after.
var plan = try scanTree(gpa, io, worktree, log, ¤t_managed, &found);
defer plan.deinit(gpa);
const out = arena.alloc(Divergence, found.items.len) catch return error.OutOfMemory;
for (found.items, out) |src, *dst| {
dst.* = .{
.path = arena.dupe(u8, src.path) catch return error.OutOfMemory,
.kind = src.kind,
};
}
return out;
}
fn requireClean(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
current_managed: *const managed.ManagedSet,
) ApplyError!Plan {
return scanTree(gpa, io, worktree, log, current_managed, null);
}
/// The one worktree-vs-check-in comparison, in both of its modes. `found` null
/// is the historical `requireClean`: the FIRST divergence returns
/// `WorktreeNotClean` and no plan. `found` non-null records every divergence
/// and returns the baseline plan regardless, for a caller that intends to merge
/// the differences rather than refuse them.
///
/// Deliberately one function and not two: the modes must agree about what
/// "clean" means down to the last stat, or `update` would refuse a tree that
/// the merge path considers unchanged (or worse, the reverse).
fn scanTree(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
current_managed: *const managed.ManagedSet,
found: ?*std.ArrayList(Divergence),
) ApplyError!Plan {
for (current_managed.iterate()) |path| {
validateWorktreePath(path) catch return error.CurrentStateInvalid;
}
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const current = checkout.read(io, worktree, &checkout_buf) catch |err| return mapCheckoutError(err);
// The cone that produced the CURRENT materialization and managed set.
// Deriving it here (not taking it as a parameter) keeps every caller --
// update, merge, revert -- incapable of comparing an in-cone managed set
// against a dense plan, the mismatch that reports every out-of-cone file
// as missing (sparse-cone RFC section 4.1).
var stored_cone = try readStoredCone(gpa, io, worktree);
defer if (stored_cone) |*c| c.deinit(gpa);
// An UNBORN record (`version == ""`) names no check-in, so there is no plan
// to build against — the same situation as an absent record, and handled by
// the same arm so the two cannot drift apart. Unborn is the normal, expected
// state of every fresh repository: `init` writes it unconditionally (and
// `clone` delegates repo creation to `init`), so without this the first
// `update` after a clone would hand the empty version to `buildPlan`, fail
// `artifact.validateName("")`, and report `CurrentStateInvalid` — a clean,
// correct tree diagnosed as broken.
if (current == null or current.?.version.len == 0) {
if (current_managed.iterate().len != 0) {
// Every managed path is an `added`: an unborn checkout names no
// check-in, so nothing on disk can match one.
if (found) |list| {
for (current_managed.iterate()) |path| {
list.append(gpa, .{ .path = path, .kind = .added }) catch return error.OutOfMemory;
}
} else return error.WorktreeNotClean;
}
// A record that names no check-in yet claims a merge parent or a
// pending special operation is incoherent, not a fresh clone: both
// describe work in progress against a check-in that the record does not
// name. Only a present record can carry them.
if (current) |c| {
if (c.merge_parent.len != 0 or c.special_op.len != 0) return error.CurrentStateInvalid;
}
return Plan{};
}
var vstat = VStat.read(gpa, io, worktree);
defer vstat.deinit(gpa);
// The sidecar-as-plan fast path: when the sidecar describes exactly the
// current check-in AND its membership agrees with the authoritative
// managed set, the baseline plan is reconstructed with no tree flatten at
// all -- removing one of the two O(width) walks that made `update` cost
// the same regardless of how little changed. A sidecar that fails either
// test is discarded (not refused): staleness costs a flatten, never an
// error, and every entry is still individually verified below before
// anything acts on it.
var plan = blk: {
if (vstat.planFor(gpa, current.?.version)) |candidate_const| {
var candidate = candidate_const;
if (sameMembership(&candidate, current_managed)) break :blk candidate;
candidate.deinit(gpa);
}
break :blk buildPlan(gpa, log, current.?.version, if (stored_cone) |*c| c else null) catch |err| switch (err) {
error.OutOfMemory, error.Storage, error.Corrupt => return err,
else => return error.CurrentStateInvalid,
};
};
errdefer plan.deinit(gpa);
const membership_drifted = !sameMembership(&plan, current_managed);
if (membership_drifted) {
if (found) |list| {
try recordMembershipDrift(gpa, list, &plan, current_managed);
} else return error.WorktreeNotClean;
}
for (plan.entries.items) |*entry| {
// A path the check-in has but the checkout no longer manages was
// already recorded as `removed` above; its on-disk state is not this
// check-in's business, so it is not content-compared. Guarded on the
// drift flag so the overwhelmingly common no-drift scan does not pay a
// set lookup per path — this loop runs once per file in the check-in.
if (membership_drifted and !current_managed.contains(entry.path)) continue;
// Stat-first: a sidecar row carrying THIS entry's digest whose inode,
// size and mtime still match the file proves it unmodified since the
// materialization that wrote both. Everything else takes the full
// content compare, which stays the authority.
if (entry.mode != .symlink) {
const existing = try statExisting(io, worktree, entry.path);
switch (existing) {
.missing => {
if (found) |list| {
list.append(gpa, .{ .path = entry.path, .kind = .missing }) catch return error.OutOfMemory;
continue;
}
return error.WorktreeNotClean;
},
.stat => |stat| if (modeMatches(stat, entry.mode) and vstat.proves(entry, stat)) continue,
}
}
if (!try pathMatchesEntry(gpa, io, worktree, log, entry)) {
if (found) |list| {
list.append(gpa, .{ .path = entry.path, .kind = .modified }) catch return error.OutOfMemory;
continue;
}
return error.WorktreeNotClean;
}
}
// Membership drift is recorded in one pass and content drift in another, so
// the two arrive grouped rather than interleaved. Sorting here makes
// "ascending by path" a property of the RESULT rather than of the order the
// passes happened to run in — which is what a caller reporting a path list
// depends on.
if (found) |list| {
std.mem.sort(Divergence, list.items, {}, struct {
fn lessThan(_: void, a: Divergence, b: Divergence) bool {
return std.mem.order(u8, a.path, b.path) == .lt;
}
}.lessThan);
}
// Postcondition: a returned plan is the current check-in's. In refusing
// mode its membership also equals the recorded managed set — the guard
// above rejected any drift — so the record and the managed set agree on
// every success path a caller of `requireClean` can reach. In collecting
// mode the drift is the answer, not a fault, so it is reported instead.
std.debug.assert(found != null or sameMembership(&plan, current_managed));
return plan;
}
/// The membership half of a dirty scan: paths the checkout manages that the
/// check-in does not (`added`), and paths the check-in has that the checkout no
/// longer manages (`removed`). Both lists are sorted and unique — the plan's
/// order is `Plan.validateTopology`'s, the managed set's is its own — so this
/// is one merge walk, not a pair of nested scans.
fn recordMembershipDrift(
gpa: std.mem.Allocator,
list: *std.ArrayList(Divergence),
plan: *const Plan,
current_managed: *const managed.ManagedSet,
) ApplyError!void {
const paths = current_managed.iterate();
var i: usize = 0; // into plan.entries (the check-in's set)
var j: usize = 0; // into paths (the managed set)
while (i < plan.entries.items.len or j < paths.len) {
const in_plan = if (i < plan.entries.items.len) plan.entries.items[i].path else null;
const in_managed = if (j < paths.len) paths[j] else null;
const order: std.math.Order = if (in_plan == null)
.gt
else if (in_managed == null)
.lt
else
std.mem.order(u8, in_plan.?, in_managed.?);
switch (order) {
.eq => {
i += 1;
j += 1;
},
.lt => {
list.append(gpa, .{ .path = in_plan.?, .kind = .removed }) catch return error.OutOfMemory;
i += 1;
},
.gt => {
list.append(gpa, .{ .path = in_managed.?, .kind = .added }) catch return error.OutOfMemory;
j += 1;
},
}
}
}
fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool {
const paths = current.iterate();
if (paths.len != plan.entries.items.len) return false;
for (paths, plan.entries.items) |path, entry| {
if (!std.mem.eql(u8, path, entry.path)) return false;
}
return true;
}
/// The stat sidecar: `(path, inode, size, mtime, digest)` for every file the
/// last materialization wrote, so the NEXT clean check can prove "unmodified
/// since we wrote it" with one stat instead of reconstructing the file's
/// plaintext out of the log. On a store with deep per-file delta chains that
/// reconstruction dominated `update` outright: a no-op update measured ~109s
/// of pure CPU, all of it `requireClean` re-deriving content that had not
/// moved.
///
/// Advisory, never authoritative. A row is trusted only when its digest is
/// the digest the CURRENT baseline expects AND inode, size and mtime all
/// still match the file on disk; anything else -- absent file, absent row,
/// stale digest, any stat drift, unreadable or malformed sidecar -- falls
/// back to the full content compare below, which remains the authority. The
/// accepted trade is fossil's own vfile trade: an edit that restores mtime,
/// size AND inode exactly is invisible to the stat check.
const VStat = struct {
/// A member of the advisory `.zit-cache/` directory, beside repocache's
/// `repo`. One root name for everything disposable.
const filename = repocache.cache_dirname ++ "/vstat";
const magic = "zitvstat";
/// v2: the sidecar carries the check-in it describes plus each entry's
/// mode, a whole-file CRC, and PLAN ORDER -- which together make it the
/// BASELINE PLAN, not merely a stat memo. A v1 file fails the version
/// check, reads as empty, and costs one flatten to rebuild.
const version: u16 = 2;
const Row = struct {
inode: u64,
size: u64,
mtime_ns: i96,
digest: [logstore.max_hash_len]u8,
digest_len: u8,
mode: artifact.FileMode,
};
const Entry = struct {
path: []const u8,
row: Row,
};
/// The check-in whose materialization wrote these rows. The rows are only
/// meaningful AS A PLAN for exactly this version; `planFor` refuses any
/// other, and staleness therefore costs a flatten rather than an error.
checkin: []const u8 = &.{},
/// In plan order -- `artifact.enumerate` emits paths sorted, `writeForPlan`
/// preserves that, and `validateTopology` depends on it.
entries: std.ArrayList(Entry) = .empty,
index: std.StringHashMapUnmanaged(usize) = .empty,
fn appendLittle(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, value: T) error{OutOfMemory}!void {
var buf: [@divExact(@typeInfo(T).int.bits, 8)]u8 = undefined;
std.mem.writeInt(T, &buf, value, .little);
try out.appendSlice(gpa, &buf);
}
fn deinit(self: *VStat, gpa: std.mem.Allocator) void {
if (self.checkin.len != 0) gpa.free(self.checkin);
for (self.entries.items) |entry| gpa.free(entry.path);
self.entries.deinit(gpa);
self.index.deinit(gpa);
self.* = undefined;
}
/// Absent, unreadable, malformed, torn, or wrong-version reads as EMPTY --
/// the sidecar can only ever remove work, never add a failure mode.
fn read(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) VStat {
var self: VStat = .{};
var file = worktree.openFile(io, filename, .{}) catch return self;
defer file.close(io);
const st = file.stat(io) catch return self;
const size: usize = std.math.cast(usize, st.size) orelse return self;
const buf = gpa.alloc(u8, size) catch return self;
defer gpa.free(buf);
const n = file.readPositionalAll(io, buf, 0) catch return self;
self.parse(gpa, buf[0..n]) catch {
self.deinit(gpa);
self = .{};
};
return self;
}
fn parse(self: *VStat, gpa: std.mem.Allocator, bytes: []const u8) !void {
const crc_len = 4;
if (bytes.len < magic.len + 4 + crc_len) return error.Corrupt;
// Whole-file CRC first: a torn write must read as absence, never as a
// shorter-but-plausible plan.
const stored_crc = std.mem.readInt(u32, bytes[bytes.len - crc_len ..][0..4], .little);
var crc = crc32.Crc32.init();
crc.update(bytes[0 .. bytes.len - crc_len]);
if (crc.final() != stored_crc) return error.Corrupt;
const body = bytes[0 .. bytes.len - crc_len];
var pos: usize = 0;
if (!std.mem.eql(u8, body[0..magic.len], magic)) return error.Corrupt;
pos = magic.len;
if (std.mem.readInt(u16, body[pos..][0..2], .little) != version) return error.Corrupt;
pos += 2;
const checkin_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little);
pos += 2;
if (body.len - pos < checkin_len) return error.Corrupt;
self.checkin = try gpa.dupe(u8, body[pos..][0..checkin_len]);
pos += checkin_len;
while (pos < body.len) {
if (body.len - pos < 2) return error.Corrupt;
const path_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little);
pos += 2;
const fixed = 8 + 8 + 12 + 1;
if (body.len - pos < path_len + fixed) return error.Corrupt;
const path = body[pos..][0..path_len];
pos += path_len;
var row: Row = undefined;
row.inode = std.mem.readInt(u64, body[pos..][0..8], .little);
pos += 8;
row.size = std.mem.readInt(u64, body[pos..][0..8], .little);
pos += 8;
row.mtime_ns = @bitCast(std.mem.readInt(u96, body[pos..][0..12], .little));
pos += 12;
row.digest_len = body[pos];
pos += 1;
if (row.digest_len > logstore.max_hash_len or body.len - pos < row.digest_len + 1) return error.Corrupt;
@memcpy(row.digest[0..row.digest_len], body[pos..][0..row.digest_len]);
pos += row.digest_len;
const mode_byte = body[pos];
pos += 1;
if (mode_byte > @backingInt(artifact.FileMode.symlink)) return error.Corrupt;
row.mode = @fromBackingInt(@intCast(mode_byte));
const owned = try gpa.dupe(u8, path);
errdefer gpa.free(owned);
const gop = try self.index.getOrPut(gpa, owned);
if (gop.found_existing) {
gpa.free(owned);
return error.Corrupt; // a duplicate path is not a record this writer produces
}
gop.value_ptr.* = self.entries.items.len;
try self.entries.append(gpa, .{ .path = owned, .row = row });
}
}
/// Whether `entry`'s file can be proven unmodified by stat alone.
fn proves(self: *const VStat, entry: *const PlanEntry, stat: std.Io.File.Stat) bool {
const at = self.index.get(entry.path) orelse return false;
const row = self.entries.items[at].row;
if (row.digest_len != entry.digest_len) return false;
if (!std.mem.eql(u8, row.digest[0..row.digest_len], entry.digestSlice())) return false;
if (row.inode != @as(u64, @bitCast(stat.inode))) return false;
if (row.size != stat.size) return false;
return row.mtime_ns == stat.mtime.nanoseconds;
}
/// The BASELINE PLAN, reconstructed with no flatten, when and only when
/// this sidecar describes exactly `checkin_id`. This is the step that
/// removes one of `update`'s two O(width) tree walks: the rows were
/// serialized from the plan the last materialization ran, in plan order,
/// under a whole-file CRC -- the same trust `.zit-managed` gets, and less
/// than `.zit-cache` gets, since every entry is still individually
/// verified (stat-first, content on any doubt) by `requireClean` before
/// anything acts on it. Null on any shortfall; the caller flattens.
fn planFor(self: *const VStat, gpa: std.mem.Allocator, checkin_id: []const u8) ?Plan {
if (self.checkin.len == 0 or !std.mem.eql(u8, self.checkin, checkin_id)) return null;
var plan = Plan{};
var ok = false;
defer if (!ok) plan.deinit(gpa);
for (self.entries.items) |entry| {
validateWorktreePath(entry.path) catch return null;
var name_buf: [64]u8 = undefined;
const name = encodeName(entry.row.digest[0..entry.row.digest_len], &name_buf);
const path = gpa.dupe(u8, entry.path) catch return null;
const owned_name = gpa.dupe(u8, name) catch {
gpa.free(path);
return null;
};
plan.entries.append(gpa, .{
.path = path,
.name = owned_name,
.digest = entry.row.digest,
.digest_len = entry.row.digest_len,
.mode = entry.row.mode,
}) catch {
gpa.free(path);
gpa.free(owned_name);
return null;
};
}
plan.validateTopology() catch return null;
ok = true;
return plan;
}
/// Stat every plan entry and write the sidecar, atomically, mirroring the
/// managed set's writer. Best-effort at every step -- EXCEPT that a file
/// that cannot be stat'ed abandons the write entirely: v2's rows are a
/// plan, and a plan missing an entry would reconstruct a baseline missing
/// a file, where v1's per-row omission only cost a content compare.
fn writeForPlan(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, plan: *const Plan, checkin_id: []const u8) void {
if (checkin_id.len == 0 or checkin_id.len > std.math.maxInt(u16)) return;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
out.appendSlice(gpa, magic) catch return;
appendLittle(gpa, &out, u16, version) catch return;
appendLittle(gpa, &out, u16, @intCast(checkin_id.len)) catch return;
out.appendSlice(gpa, checkin_id) catch return;
var parents: ParentCache = .{};
defer parents.deinit(io);
// A carried path holds the working tree's bytes, which the plan's
// digest does not describe: the row would prove the file "unmodified
// since we wrote it" to the next `scanTree` and hide a real local edit.
// Omitting just that row is not the alternative — by the rule above, a
// plan missing an entry reconstructs a baseline missing a file, and
// that baseline is what `stagePlan` writes as the rollback image. So a
// carried plan abandons the sidecar wholesale, exactly as an
// unstat-able path does. The cost is one flatten on the next update.
for (plan.entries.items) |entry| {
if (entry.carry != null) return;
}
for (plan.entries.items) |entry| {
if (entry.path.len > std.math.maxInt(u16)) return;
const existing = statExistingCached(&parents, io, worktree, entry.path) catch return;
const stat = switch (existing) {
.missing => return,
.stat => |value| value,
};
appendLittle(gpa, &out, u16, @intCast(entry.path.len)) catch return;
out.appendSlice(gpa, entry.path) catch return;
appendLittle(gpa, &out, u64, @bitCast(stat.inode)) catch return;
appendLittle(gpa, &out, u64, stat.size) catch return;
appendLittle(gpa, &out, u96, @bitCast(stat.mtime.nanoseconds)) catch return;
appendLittle(gpa, &out, u8, entry.digest_len) catch return;
out.appendSlice(gpa, entry.digestSlice()) catch return;
appendLittle(gpa, &out, u8, @backingInt(entry.mode)) catch return;
}
var crc = crc32.Crc32.init();
crc.update(out.items);
appendLittle(gpa, &out, u32, crc.final()) catch return;
if (!repocache.ensureCacheDir(io, worktree)) return;
var af = worktree.createFileAtomic(io, filename, .{ .replace = true }) catch return;
defer af.deinit(io);
af.file.writePositionalAll(io, out.items, 0) catch return;
af.file.sync(io) catch return;
af.replace(io) catch return;
}
};
fn pathMatchesEntry(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
entry: *const PlanEntry,
) ApplyError!bool {
const existing = try statExisting(io, worktree, entry.path);
const stat = switch (existing) {
.missing => return false,
.stat => |value| value,
};
switch (entry.mode) {
.symlink => if (stat.kind != .sym_link) return false,
.regular, .executable => if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false,
}
const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid;
const expected = log.content(gpa, rid) catch |err| return mapContentError(err);
if (expected == null) return error.CurrentStateInvalid;
defer gpa.free(expected.?);
if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid;
const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => return false,
else => return @errorCast(err),
};
defer parent.close(io);
switch (entry.mode) {
.symlink => {
const actual = gpa.alloc(u8, std.math.add(usize, expected.?.len, 1) catch return error.OutOfMemory) catch return error.OutOfMemory;
defer gpa.free(actual);
const n = parent.dir.readLink(io, parent.name, actual) catch return false;
return n == expected.?.len and std.mem.eql(u8, actual[0..n], expected.?);
},
.regular, .executable => {
if (stat.size != expected.?.len) return false;
var file = parent.dir.openFile(io, parent.name, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
}) catch return false;
defer file.close(io);
const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory;
defer gpa.free(actual);
const n = file.readPositionalAll(io, actual, 0) catch return error.Storage;
return n == actual.len and std.mem.eql(u8, actual, expected.?);
},
}
}
/// What one materialization needs out of the derived repository state: the
/// branch the record commits, and — only when the `t` selector asked for it —
/// the check-in's symbolic tag names. Both come from ONE state derive; a second
/// pass just for the tags would double the most expensive read on the path.
const TargetFacts = struct {
branch: []u8,
/// One symbolic tag name per line, newline-terminated; empty when `t` was
/// not requested (and when the check-in carries no symbolic tag).
tags: []u8,
fn deinit(self: *TargetFacts, gpa: std.mem.Allocator) void {
gpa.free(self.branch);
gpa.free(self.tags);
self.* = undefined;
}
};
/// The cached-view fast path for the facts below: the same questions answered
/// from the `.zit-cache`-backed `Repository` view instead of `assembleState`'s
/// from-scratch sweep over every artifact in the store. On a warm cache this
/// is milliseconds where the sweep was tens of seconds, and the answers are
/// the same by the cache's own contract -- the view it serves is proven
/// equivalent to a cold derive. Null on ANY shortfall (no cache, open failure,
/// target not in the view), and the caller falls back to the sweep, which
/// stays the authority.
fn cachedTargetFacts(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
target_id: []const u8,
want_tags: bool,
shared: ?*repository.Repository,
) ?TargetFacts {
var own: repository.Repository = undefined;
var opened_here = false;
const repo: *repository.Repository = shared orelse blk: {
own.open(gpa, io, root_dir, .{ .mode = .full }) catch return null;
opened_here = true;
break :blk &own;
};
defer if (opened_here) own.deinit(gpa);
const rid = repo.lookup(target_id) orelse return null;
const owned_branch = gpa.dupe(u8, repo.branchOf(rid)) catch return null;
errdefer gpa.free(owned_branch);
const tags: []u8 = if (want_tags)
tagsListing(gpa, repo.state(), rid, owned_branch) catch {
gpa.free(owned_branch);
return null;
}
else
&.{};
return .{ .branch = owned_branch, .tags = tags };
}
fn deriveTargetFacts(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
target_id: []const u8,
want_tags: bool,
) ApplyError!TargetFacts {
var assembly = try assembleState(gpa, log, target_id);
defer assembly.deinit();
var config = log.readConfig(gpa) catch |err| return mapContentError(err);
defer if (config) |*owned| owned.deinit(gpa);
// Storage deliberately leaves an absent config to its caller. The
// repository-state convention is the same `trunk` fallback used by the
// existing state tests; a present config always overrides it.
// An empty stored value is treated as unset, matching `RepoView.loadInner`;
// `branchOf` requires a non-empty name and an empty one would trip it.
const main_branch = blk: {
if (config) |owned| {
if (owned.config.default_branch.len != 0) break :blk owned.config.default_branch;
}
break :blk "trunk";
};
// This assembly builds its own bare `RepoState`, so nothing has told it
// §6.1's configured name -- `RepoView` is what does that on the ordinary
// load path. Tell it here, at the point the fact becomes known, so the
// state's own `branchValueOf` (the §7.2 leaf test) and `branchOf` below
// resolve an untagged check-in to the SAME branch. Leaving them to
// disagree is what made an imported repository's untagged check-ins
// display as one branch and leaf-test as another.
assembly.state.main_branch = main_branch;
const branch = assembly.state.branchOf(assembly.target_rid, main_branch);
const owned_branch = gpa.dupe(u8, branch) catch return error.OutOfMemory;
errdefer gpa.free(owned_branch);
const tags: []u8 = if (want_tags)
try tagsListing(gpa, &assembly.state, assembly.target_rid, branch)
else
&.{};
return .{ .branch = owned_branch, .tags = tags };
}
/// Fossil's `manifest.tags` layout: the branch line, then one `tag` line per
/// SYMBOLIC tag active on this check-in. Reserved non-symbolic names
/// ("branch", "closed", ...) are not names a user checks out and are excluded,
/// as fossil excludes them. Fossil emits its tag lines in tag-creation order,
/// which no other repository can reproduce; `tagsOf` returns the active rows
/// name-ascending, so zit's file is the same lines in a deterministic order.
fn tagsListing(gpa: std.mem.Allocator, state: *const repostate.RepoState, rid: repostate.Rid, branch: []const u8) ApplyError![]u8 {
const sym_prefix = "sym-";
const entries = state.tagsOf(gpa, rid) catch return error.OutOfMemory;
defer gpa.free(entries);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
out.print(gpa, "branch {s}\n", .{branch}) catch return error.OutOfMemory;
for (entries) |e| {
if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
out.print(gpa, "tag {s}\n", .{e.name[sym_prefix.len..]}) catch return error.OutOfMemory;
}
return out.toOwnedSlice(gpa) catch return error.OutOfMemory;
}
/// State-record classification is intentionally conservative: any successful
/// classified manifest is crosslinked, any successful classified control
/// artifact ingests only tag effects, and all other successful classifications
/// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque:
/// the log frame format has no structural-kind bit to distinguish a blob from
/// malformed cards. Integrity failures remain fatal.
///
/// A classification failure is NOT fatal, and is not evidence of damage. A
/// conforming repository contains artifacts a conforming parser declines: the
/// artifact grammar requires the U card on a WIKI artifact, fossil elides it
/// when the submitter is "nobody", and fossil stores and syncs the result, so a
/// clone of an intact fossil repository carries such artifacts. Repository
/// state §4 separates the two conditions by the name: bytes that hash to the
/// name they are stored under are stored correctly and merely unclassifiable,
/// so they are opaque to state and skipped; bytes that do not are an integrity
/// failure and stay fatal. Verification is what distinguishes them, so it runs
/// before the skip rather than after — see the arm below.
///
/// This is why assembly must not answer a decline with a corruption claim: the
/// verified name has already ruled damage out.
pub fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly {
const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory;
mem.* = repostate.MemStore.init(gpa);
errdefer {
mem.deinit();
gpa.destroy(mem);
}
var state = repostate.RepoState.init(gpa, mem.store());
errdefer state.deinit();
var target_rid: ?repostate.Rid = null;
const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory;
defer gpa.free(persisted_rids);
// Same memo, same reason as `RepoView.loadInner`: bases persist strictly
// before their deltas, so persisted-order resolution keeps each base hot
// and collapses delta-chain read-amplification to ~1x. This walk ran
// WITHOUT one while duplicating loadInner's sweep, which made every
// update and commit re-reconstruct each delta chain from its root.
var plaintext = logstore.PlaintextCache.init();
defer plaintext.deinit(gpa);
var raw_name: [64]u8 = undefined;
for (persisted_rids) |rid| {
const name = encodeName(log.hashOf(rid), &raw_name);
const bytes = log.contentCached(gpa, rid, &plaintext) catch |err| return mapContentError(err);
if (bytes == null) continue;
defer gpa.free(bytes.?);
// Read path: repostate recovery over persisted (fossil-origin) history
// tolerates the legacy F-card trailing separator.
var parsed = artifact.parseForImport(gpa, bytes.?) catch |err| switch (err) {
error.SyntaxError => continue,
error.OutOfMemory => return error.OutOfMemory,
// Classification declined the artifact. Repository state §4 splits
// this from damage by the name: bytes that hash to the name they
// are stored under arrived and were stored correctly, so the only
// event is that this reader's grammar does not admit them. Such an
// artifact is opaque to state — it is skipped exactly as a
// non-participating type is. Bytes that do NOT hash to their name
// are the other condition, and stay fatal.
//
// A conforming repository reaches this: the artifact grammar makes
// the U card required on a WIKI artifact, fossil elides it when the
// submitter is "nobody", and fossil stores and syncs the result.
else => {
if (artifact.verify(name, bytes.?)) continue;
return error.Corrupt;
},
};
defer parsed.deinit(gpa);
switch (parsed.manifest.kind) {
.manifest => {
if (!artifact.verify(name, bytes.?)) return error.Corrupt;
const record = try manifestRecord(gpa, name, &parsed.manifest);
defer {
gpa.free(record.parents);
gpa.free(record.tags);
}
const state_rid = state.crosslink(record) catch return error.OutOfMemory;
if (std.mem.eql(u8, name, target_id)) target_rid = state_rid;
},
.control => {
if (!artifact.verify(name, bytes.?)) return error.Corrupt;
const record = try controlRecord(gpa, &parsed.manifest);
defer gpa.free(record.tags);
_ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory;
},
else => {},
}
}
return .{
.gpa = gpa,
.mem = mem,
.state = state,
// Unreachable on the shipped path, and deliberately not given its own
// variant for that reason. `applyCheckin` runs `buildPlan` on this same
// `target_id` first, and `buildPlan` already proved the name valid, the
// rid present, the content non-null, `artifact.verify` passing,
// `parseForImport` succeeding and `kind == .manifest`. The loop above
// re-parses those same bytes with the same two tests, and
// `ridsInPersistedOrder` filters only phantoms — which a non-null
// `content` already excluded — so the target is always crosslinked by
// the time this runs. It stays a state-record failure rather than a
// user error: were it ever to fire it would mean the assembler and the
// log disagree, which is not something the caller named wrongly.
.target_rid = target_rid orelse return error.StateRecordInvalid,
};
}
const StateRecordParts = struct {
mtime: repostate.Mtime,
parents: []const []const u8 = &.{},
tags: []const repostate.TagOp = &.{},
};
fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record {
const parts = try stateRecordParts(gpa, manifest, true);
return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags };
}
fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts {
return stateRecordParts(gpa, manifest, false);
}
fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts {
const date = manifest.d orelse return error.StateRecordInvalid;
const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid;
var parents = std.ArrayList([]const u8).empty;
errdefer parents.deinit(gpa);
if (include_parents) {
for (manifest.p) |card| {
var it = card.hashes();
while (it.next()) |hash| {
parents.append(gpa, hash) catch return error.OutOfMemory;
}
}
}
const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory;
errdefer gpa.free(tags);
for (manifest.t, tags) |card, *out| {
out.* = .{
.type = switch (card.sign) {
.add => .singleton,
.cancel => .cancel,
.propagate => .propagate,
},
.name = card.name,
.target = card.target,
.value = card.value,
};
}
return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags };
}
/// Which file staging could not materialize. Plain data, no allocation: the
/// path borrows the plan, which outlives the call, and the name is copied.
///
/// This exists because staging walks every file in the check-in — thousands on
/// a real repository, minutes of work — and a bare `Corrupt` at the end of that
/// says only that something, somewhere, is wrong. The path and hash are what
/// turn it into a question someone can answer: is that artifact in the source
/// repository, and did it fail to arrive?
pub const StageFault = struct {
/// COPIED, not borrowed. `PlanEntry.path` is `gpa.dupe`d and freed by
/// `Plan.deinit`, which `applyCheckin` runs from its own `defer` on the way
/// out — so it is already gone by the time a caller renders this. An earlier
/// version of this struct held the slice and documented it as borrowing a
/// plan that "outlives the call"; it does not, and the diagnostic printed
/// freed heap. A fault report that lies about the file is worse than the
/// generic message it replaced.
path_buf: [path_max]u8 = @as([path_max]u8, @splat(0)),
path_len: u16 = 0,
/// Also copied, for the same reason: `PlanEntry.name` is owned by the plan.
name: [64]u8 = @as([64]u8, @splat(0)),
name_len: u8 = 0,
reason: enum { not_in_repository, content_absent, content_mismatch },
/// Long enough for any path this repository can hold (`managed.max_path_len`
/// is PATH_MAX), so a diagnostic never truncates a real filename.
pub const path_max: usize = 4096;
pub fn artifactName(self: *const StageFault) []const u8 {
return self.name[0..self.name_len];
}
pub fn filePath(self: *const StageFault) []const u8 {
return self.path_buf[0..self.path_len];
}
};
/// Human-readable cause for the last staging fault, or a neutral line if none
/// was recorded. Shared so every verb that can hit this says the same thing
/// about the same failure.
pub fn stageFaultReason(buf: []u8) []const u8 {
const f = last_stage_fault orelse
return "a file's content is missing from this repository";
const why: []const u8 = switch (f.reason) {
.not_in_repository => "is not in this repository",
.content_absent => "has no stored content in this repository",
.content_mismatch => "does not match its recorded name",
};
return std.fmt.bufPrint(buf, "'{s}': content {s} {s}", .{ f.filePath(), f.artifactName(), why }) catch
"a file's content is missing from this repository";
}
/// Why a referenced file can be absent from a repository that verifies clean.
///
/// Shared by every verb that can hit `FileContentMissing`, because the cause is
/// the same in all of them and it is not guessable from the missing path. The
/// format types artifacts by STRUCTURE (artifact-format §5.1) and repo-state
/// §4.4 exempts only what a parser declines to classify — so a stored blob whose
/// bytes are a valid check-in manifest IS a check-in, joins the DAG, and can
/// carry its own branch tags. A repository that tracks such a file therefore
/// holds check-ins of ANOTHER repository, whose file content it never had.
///
/// That is conformant derivation, not damage, which is why the refusal explains
/// rather than alleging corruption.
pub const foreign_checkin_note: []const u8 =
"this happens when a repository stores artifacts that parse as check-ins of\n" ++
"ANOTHER repository -- imported mirror metadata is the common source -- so they\n" ++
"join the branch as check-ins whose blobs were never here. Nothing was modified.\n";
/// Set by `stagePlan` when it refuses a file; read only after `applyCheckin`
/// returns `error.FileContentMissing`.
pub var last_stage_fault: ?StageFault = null;
fn stageFaulted(entry_path: []const u8, name: []const u8, reason: anytype) error{FileContentMissing} {
var f: StageFault = .{ .reason = reason };
const n = @min(name.len, f.name.len);
@memcpy(f.name[0..n], name[0..n]);
f.name_len = @intCast(n);
const pn = @min(entry_path.len, StageFault.path_max);
@memcpy(f.path_buf[0..pn], entry_path[0..pn]);
f.path_len = @intCast(pn);
last_stage_fault = f;
return error.FileContentMissing;
}
/// Mark, in BOTH plans, every path whose (digest, mode) pair is identical on
/// the two sides. Everything else -- content moved, mode flipped, path only on
/// one side -- stays unmarked and takes the full mutation path.
///
/// This is what makes `update`'s cost track the DIFF rather than the width:
/// before it, every update staged, journaled, deleted and rewrote the entire
/// tree, and the measured cost was ~52ms per tracked file regardless of how
/// little changed. It is sound only downstream of `requireClean`, which has
/// already proven every baseline file byte-correct on disk -- an unchanged
/// entry is not "skipped work", it is work whose result is already present.
fn markUnchanged(gpa: std.mem.Allocator, target: *Plan, old: *Plan) error{OutOfMemory}!void {
var by_path: std.StringHashMapUnmanaged(*PlanEntry) = .empty;
defer by_path.deinit(gpa);
try by_path.ensureTotalCapacity(gpa, @intCast(old.entries.items.len));
for (old.entries.items) |*entry| by_path.putAssumeCapacity(entry.path, entry);
for (target.entries.items) |*entry| {
const prior = by_path.get(entry.path) orelse continue;
if (prior.mode != entry.mode) continue;
if (!std.mem.eql(u8, prior.digestSlice(), entry.digestSlice())) continue;
entry.unchanged = true;
prior.unchanged = true;
}
}
fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void {
var parents: ParentCache = .{};
defer parents.deinit(io);
for (plan.entries.items) |entry| {
if (entry.unchanged) continue;
// A carried path's bytes are the working tree's, not the store's:
// there is no artifact to look up and nothing to verify them against.
// They are staged like any other payload so the commit/rollback rename
// machinery below treats carried and check-in content identically.
if (entry.carry) |bytes| {
try writePayload(io, stage, entry.path, bytes, entry.mode, true, null, false, &parents);
continue;
}
const rid = log.lookup(entry.digestSlice()) orelse
return stageFaulted(entry.path, entry.name, .not_in_repository);
const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) return stageFaulted(entry.path, entry.name, .content_absent);
defer gpa.free(bytes.?);
if (!artifact.verify(entry.name, bytes.?))
return stageFaulted(entry.path, entry.name, .content_mismatch);
try writePayload(io, stage, entry.path, bytes.?, entry.mode, true, null, false, &parents);
}
}
fn preflight(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
target: *const Plan,
current_managed: *const managed.ManagedSet,
) ApplyError!void {
// Ancestors already proven to be directories (or absent). A path-sorted
// plan names the same few ancestors thousands of times; without this each
// entry re-walked and re-stat'd every level above it from the root.
var known_dirs: std.StringHashMapUnmanaged(void) = .empty;
defer known_dirs.deinit(gpa);
var parents: ParentCache = .{};
defer parents.deinit(io);
for (target.entries.items) |entry| {
if (entry.unchanged) continue;
var replaces_managed_ancestor = false;
var component_end: usize = 0;
while (std.mem.indexOfScalarPos(u8, entry.path, component_end, '/')) |slash| {
const parent = entry.path[0..slash];
if (known_dirs.contains(parent)) {
component_end = slash + 1;
continue;
}
const existing = try statExistingCached(&parents, io, worktree, parent);
switch (existing) {
.missing => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory,
.stat => |stat| switch (stat.kind) {
.directory => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory,
.file, .sym_link => if (current_managed.contains(parent)) {
replaces_managed_ancestor = true;
break;
} else return error.UnmanagedCollision,
else => return error.UnmanagedCollision,
},
}
component_end = slash + 1;
}
if (replaces_managed_ancestor) continue;
const existing = try statExistingCached(&parents, io, worktree, entry.path);
switch (existing) {
.missing => {},
.stat => |stat| switch (stat.kind) {
.file, .sym_link => if (!current_managed.contains(entry.path)) return error.UnmanagedCollision,
.directory => if (!try directoryContainsOnlyManaged(gpa, io, worktree, entry.path, current_managed)) return error.UnmanagedCollision,
else => return error.UnmanagedCollision,
},
}
}
}
fn commitMaterialization(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
next: std.Io.Dir,
previous: std.Io.Dir,
target: *const Plan,
old: *const Plan,
desired_managed: *const managed.ManagedSet,
old_managed: *const managed.ManagedSet,
created_directories: *CreatedDirectories,
next_checkout: checkout.CheckoutState,
manifest: ManifestSource,
cone_update: ?checkout.SectionUpdate,
) ApplyError!void {
_ = previous;
_ = old_managed;
// Preconditions of the commit seam — the managed-set / record / staged-tree
// agreement this transition rests on, self-guarded here rather than trusted
// from the caller:
// (iv) the record about to be written names EXACTLY the check-in whose
// payload `next` was staged from, so a completed commit never leaves
// a record pointing at a materialization that did not run; and
// the managed set to be written is that check-in's membership (one entry
// per target plan path).
std.debug.assert(std.mem.eql(u8, next_checkout.version, manifest.target_id));
std.debug.assert(desired_managed.iterate().len == target.entries.items.len);
try removePlanFiles(io, worktree, old, true);
try materializePlan(gpa, io, next, worktree, target, created_directories, .move);
if (shouldInjectTestFault(.after_materialize)) return error.Storage;
// Before the metadata writes, so a failure here still takes the ordinary
// rollback rather than stranding a committed record beside a half-written
// convenience file.
try writeManifestFiles(gpa, io, worktree, next_checkout, desired_managed, manifest);
// The membership and the record commit in ONE container write: there is no
// window in which a crash leaves a new managed set beside an old checkout.
const mbuf = gpa.alloc(u8, desired_managed.bytes) catch return error.OutOfMemory;
defer gpa.free(mbuf);
var ck_buf: [checkout.max_ck_payload]u8 = undefined;
if (cone_update) |u| {
std.debug.assert(u.tag == .cone); // the seam widens for exactly one tag
checkout.updateSections(io, worktree, &.{
.{ .tag = .managed, .payload = desired_managed.encode(mbuf) },
.{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, next_checkout) },
u,
}) catch |err| return mapCheckoutError(err);
} else {
checkout.updateSections(io, worktree, &.{
.{ .tag = .managed, .payload = desired_managed.encode(mbuf) },
.{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, next_checkout) },
}) catch |err| return mapCheckoutError(err);
}
}
/// Where `writeManifestFiles` reads its payloads from: the log holding the
/// check-in artifact, the check-in's own name, and the tag listing already
/// derived for this target (empty unless `t` was selected).
const ManifestSource = struct {
log: *logstore.LogStore,
target_id: []const u8,
tags: []const u8,
};
/// Write the working-tree manifest files `next_checkout.manifest_flags` selects.
/// No RFC section behind this: it is fossil's `manifest` setting re-expressed as
/// an explicit per-checkout opt-in, and the file contents match fossil's byte
/// for byte so a build that reads them cannot tell the two apart.
///
/// `r` is the check-in artifact followed by fossil's sterilizing line, `u` is
/// the check-in name plus a newline, `t` is the branch line and one line per
/// symbolic tag. A selector whose file name is TRACKED by the target check-in
/// is skipped: fossil has that guard and the reason is the same — a versioned
/// file the user committed outranks a convenience file, and silently replacing
/// it would lose data the repository is responsible for. No selector means no
/// write at all, which is the default.
fn writeManifestFiles(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
next_checkout: checkout.CheckoutState,
tracked: *const managed.ManagedSet,
src: ManifestSource,
) ApplyError!void {
const flags = checkout.manifestSelectors(next_checkout.manifest_flags);
if (flags.len == 0) return;
std.debug.assert(flags.len <= checkout.manifest_selectors.len);
for (flags) |selector| {
const index = std.mem.indexOfScalar(u8, checkout.manifest_selectors, selector) orelse
return error.CurrentStateInvalid; // a record carrying a selector no writer knows
const path = manifest_paths[index];
if (tracked.contains(path)) continue;
switch (selector) {
'r' => {
var digest: [logstore.max_hash_len]u8 = undefined;
const raw = try decodeValidatedName(src.target_id, &digest);
const rid = src.log.lookup(raw) orelse return error.CheckinNotFound;
const bytes = src.log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) return error.CheckinContentMissing;
defer gpa.free(bytes.?);
// The file is the check-in artifact verbatim, so it must still
// hash to its own name before anything is written.
if (!artifact.verify(src.target_id, bytes.?)) return error.CheckinNameMismatch;
// ...and then the sterilizing line, byte-for-byte fossil's.
// Without it the file on disk IS a valid artifact, so adding it
// to a check-in would commit a second copy of that check-in
// under a name that is not its own. Fossil defends against that
// and so must zit; a build reading the D or C card is unaffected.
var out: std.ArrayList(u8) = .empty;
defer out.deinit(gpa);
out.appendSlice(gpa, bytes.?) catch return error.OutOfMemory;
out.appendSlice(gpa, sterilizer) catch return error.OutOfMemory;
try writePayload(io, worktree, path, out.items, .regular, false, null, true, null);
},
'u' => {
// 64 hex digits is the longest canonical name (§3.2); the
// `buildPlan` gate has already proven `target_id` is one.
var line: [2 * logstore.max_hash_len + 1]u8 = undefined;
std.debug.assert(artifact.validateName(src.target_id));
@memcpy(line[0..src.target_id.len], src.target_id);
line[src.target_id.len] = '\n';
try writePayload(io, worktree, path, line[0 .. src.target_id.len + 1], .regular, false, null, true, null);
},
't' => try writePayload(io, worktree, path, src.tags, .regular, false, null, true, null),
else => unreachable, // the selector table above is exhaustive
}
}
}
fn rollbackMaterialization(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
previous: std.Io.Dir,
target: *const Plan,
old: *const Plan,
old_managed: *const managed.ManagedSet,
created_directories: *const CreatedDirectories,
) ApplyError!void {
try removePlanFiles(io, worktree, target, false);
try created_directories.remove(io, worktree);
try materializePlan(gpa, io, previous, worktree, old, null, .copy);
old_managed.write(io, worktree) catch |err| return mapManagedError(err);
}
fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void {
for (plan.entries.items) |entry| {
if (entry.unchanged) continue;
const removed = blk: {
const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => break :blk false,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
error.FileNotFound => break :blk false,
else => return error.Storage,
};
break :blk true;
};
if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path);
}
}
/// Deletes only empty ancestors of a removed managed file. A non-empty
/// directory is never recursed into, so unmanaged entries remain untouched.
fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void {
var index = path.len;
while (index > 0) {
index -= 1;
if (path[index] != '/') continue;
const ancestor = path[0..index];
const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) {
error.PathMissing => continue,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.FileNotFound, error.DirNotEmpty => {},
else => return error.Storage,
};
}
}
/// How a staged payload reaches the working tree. `.move` renames it into
/// place -- the stage lives inside the worktree, so it is one same-filesystem
/// rename per entry, and the bytes are neither read back nor written twice.
/// `.copy` reads and rewrites, leaving the staged tree intact: the rollback
/// path uses it so a failed inverse still has `previous/` as its complete
/// recovery copy (see the retain branch in `applyCheckin`).
const Transfer = enum { move, copy };
fn materializePlan(
gpa: std.mem.Allocator,
io: std.Io,
stage: std.Io.Dir,
worktree: std.Io.Dir,
plan: *const Plan,
created_directories: ?*CreatedDirectories,
transfer: Transfer,
) ApplyError!void {
var sources: ParentCache = .{};
defer sources.deinit(io);
var destinations: ParentCache = .{};
defer destinations.deinit(io);
for (plan.entries.items) |entry| {
if (entry.unchanged) continue;
try prepareDestination(io, worktree, entry.path, &destinations);
const source_parent = openParentCached(&sources, io, stage, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
switch (transfer) {
.move => {
const dest = openParentCached(&destinations, io, worktree, entry.path, true, created_directories) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
// A plain rename, not `renamePreserve`: on Darwin the latter
// is link+unlink, and that linkat was half the verb's wall
// time. `preflight` already proved the slot is free or
// managed-and-removed, so there is nothing to preserve.
source_parent.dir.rename(source_parent.name, dest.dir, dest.name, io) catch return error.Storage;
},
.copy => switch (entry.mode) {
.symlink => {
const stat = source_parent.dir.statFile(io, source_parent.name, .{ .follow_symlinks = false }) catch return error.Storage;
if (stat.kind != .sym_link) return error.Storage;
const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
defer gpa.free(bytes);
const n = source_parent.dir.readLink(io, source_parent.name, bytes) catch return error.Storage;
if (n != bytes.len) return error.Storage;
try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories, false, &destinations);
},
.regular, .executable => {
var source = source_parent.dir.openFile(io, source_parent.name, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
}) catch return error.Storage;
defer source.close(io);
const stat = source.stat(io) catch return error.Storage;
const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
defer gpa.free(bytes);
const n = source.readPositionalAll(io, bytes, 0) catch return error.Storage;
if (n != bytes.len) return error.Storage;
try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories, false, &destinations);
},
},
}
}
}
fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8, cache: ?*ParentCache) ApplyError!void {
const existing = try statExistingCached(cache, io, worktree, path);
switch (existing) {
.missing => {},
.stat => |stat| switch (stat.kind) {
.directory => {
const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.DirNotEmpty => return error.UnmanagedCollision,
else => return error.Storage,
};
},
else => return error.UnmanagedCollision,
},
}
}
/// `sync` fsyncs a regular file before returning. The bulk plan walks pass
/// false: fossil fsyncs nothing on checkout, and an fsync per staged file was
/// ~8% of a tree materialization. The single-file writers (`revert`,
/// the manifest stamps) keep it. `parents`, when given, amortizes the
/// directory walk across a path-sorted plan.
fn writePayload(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
bytes: []const u8,
mode: artifact.FileMode,
exclusive: bool,
created_directories: ?*CreatedDirectories,
sync: bool,
parents: ?*ParentCache,
) ApplyError!void {
const parent = (if (parents) |cache|
openParentCached(cache, io, root, path, true, created_directories)
else
openParentNoFollow(io, root, path, true, created_directories)) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
switch (mode) {
.symlink => {
if (!exclusive) {
parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
error.FileNotFound => {},
else => return error.Storage,
};
}
parent.dir.symLink(io, bytes, parent.name, .{}) catch return error.Storage;
},
.regular, .executable => {
var file = parent.dir.createFile(io, parent.name, .{
.read = false,
.exclusive = exclusive,
.truncate = true,
.permissions = switch (mode) {
.regular => .default_file,
.executable => .executable_file,
.symlink => unreachable,
},
.resolve_beneath = true,
}) catch return error.Storage;
defer file.close(io);
// `permissions` applies only when the file is created; an
// overwrite keeps the old mode. Set the execute bits so a mode
// change reaches an existing file too, as Fossil does: execute
// wherever the file is readable, or none.
if (!exclusive) try setExecutable(io, file, mode == .executable);
file.writePositionalAll(io, bytes, 0) catch return error.Storage;
if (sync) file.sync(io) catch return error.Storage;
},
}
}
/// The closed error set `restoreFile` surfaces: a storage failure while
/// materializing the requested working-tree entry. `SymlinkUnsupported` stays
/// in the public set for compatibility but is no longer returned for F-cards.
pub const RestoreError = error{ Storage, SymlinkUnsupported };
/// Overwrite the single worktree file `path` under `root` with `bytes` at
/// `mode` (artifact §8.4 effective-file restore), creating any missing parent
/// directories. Unlike the transactional `applyCheckin`, this is a targeted,
/// non-exclusive write of ONE already-resolved file: `zit revert` reconstructs
/// the check-in's blob and hands it here, leaving the managed set untouched.
/// Symlink targets are created from `bytes` exactly; all materialization
/// failures surface as `Storage`. Regular-file writes are fsync'd before
/// returning.
pub fn restoreFile(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
bytes: []const u8,
mode: artifact.FileMode,
) RestoreError!void {
writePayload(io, root, path, bytes, mode, false, null, true, null) catch |e| switch (e) {
error.SymlinkUnsupported => return error.SymlinkUnsupported,
else => return error.Storage,
};
}
/// Delete the single worktree file `path` under `root`, pruning any parent
/// directories the delete leaves empty — the symmetric inverse of `restoreFile`
/// (`zit undo` uses it to remove a file the undone mutation created). Reuses the
/// same no-follow parent walk and empty-ancestor prune the transactional path
/// uses; an already-absent path is success, not an error. Fsync of the
/// containing directory is the OS's to schedule; the delete is durable-ordered
/// after the journal swap that authorized it.
pub fn removeFile(io: std.Io, root: std.Io.Dir, path: []const u8) RestoreError!void {
const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
error.PathMissing => return, // parent gone => nothing to delete
else => return error.Storage,
};
defer parent.close(io);
parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
error.FileNotFound => return, // already absent
else => return error.Storage,
};
pruneEmptyPlanAncestors(io, root, path) catch return error.Storage;
}
// ---------------------------------------------------------------------------
// Merge materialization (checkout-layer seam for `zit merge`).
//
// The CLI computes a three-way merge purely, then hands the outcome set here as
// `MergeEntry` values. This layer owns the transactional filesystem effects,
// reusing the same journal + write-temp+fsync + rollback primitives
// `applyCheckin` uses. It never inspects bytes for conflict markers — surfacing
// conflicts is the CLI's job.
// ---------------------------------------------------------------------------
/// Verify the working tree is clean against its current checkout — the same
/// `requireClean` gate `applyCheckin` runs (every managed file matches its
/// checked-in content and the membership has not drifted), discarding the Plan.
/// `zit merge`'s refusal gate calls this to reject a dirty tree (B-merge-14)
/// before any mutation; a clean tree returns void, a dirty one `WorktreeNotClean`.
/// No mutation.
pub fn requireCleanTree(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
) ApplyError!void {
var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer current_managed.deinit();
var plan = try requireClean(gpa, io, worktree, log, ¤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.
/// Sparse-cone RFC section 4.4 -- one resolved out-of-cone outcome of a
/// cross-cone merge, held in the checkout container's merge_overlay section
/// until the next commit composes it. `hash == null` is theirs' deletion.
pub const OverlayEntry = struct {
path: []const u8,
hash: ?[]const u8,
perm: ?[]const u8,
};
/// merge_overlay wire form: magic "ZITOVLY1" | count u16 LE | per entry:
/// flag u8 (1 = write, 0 = delete) | path u16 LE + bytes | hash u8 + bytes
/// (write only) | perm u8 + bytes (write only, 0 = none). Fail-closed parse:
/// authoritative state, never best-effort.
const overlay_magic = "ZITOVLY1";
pub fn encodeOverlay(gpa: std.mem.Allocator, entries: []const OverlayEntry) error{ OutOfMemory, Overflow }![]u8 {
// Explicit rejections first (release_assert rule 5): the asserts below
// restate already-established facts, never guard in release builds.
if (entries.len == 0) return error.Overflow; // an empty overlay is spelled "no section"
if (entries.len > std.math.maxInt(u16)) return error.Overflow;
std.debug.assert(entries.len >= 1);
std.debug.assert(entries.len <= std.math.maxInt(u16));
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
try out.appendSlice(gpa, overlay_magic);
var n16: [2]u8 = undefined;
std.mem.writeInt(u16, &n16, @intCast(entries.len), .little);
try out.appendSlice(gpa, &n16);
for (entries) |entry| {
if (entry.path.len == 0 or entry.path.len > std.math.maxInt(u16)) return error.Overflow;
if (entry.hash) |h| {
if (h.len == 0 or h.len > std.math.maxInt(u8)) return error.Overflow;
if (entry.perm) |perm_check| {
if (perm_check.len > std.math.maxInt(u8)) return error.Overflow;
}
}
try out.append(gpa, if (entry.hash != null) 1 else 0);
std.mem.writeInt(u16, &n16, @intCast(entry.path.len), .little);
try out.appendSlice(gpa, &n16);
try out.appendSlice(gpa, entry.path);
if (entry.hash) |h| {
try out.append(gpa, @intCast(h.len));
try out.appendSlice(gpa, h);
const perm = entry.perm orelse "";
try out.append(gpa, @intCast(perm.len));
try out.appendSlice(gpa, perm);
}
}
return out.toOwnedSlice(gpa);
}
/// Entries borrow `bytes`; the arena (or caller) owns both lifetimes.
pub fn decodeOverlay(gpa: std.mem.Allocator, bytes: []const u8) error{ OutOfMemory, Corrupt }![]OverlayEntry {
if (bytes.len < overlay_magic.len + 2) return error.Corrupt;
if (!std.mem.eql(u8, bytes[0..overlay_magic.len], overlay_magic)) return error.Corrupt;
const count = std.mem.readInt(u16, bytes[overlay_magic.len..][0..2], .little);
if (count == 0) return error.Corrupt;
const entries = try gpa.alloc(OverlayEntry, count);
errdefer gpa.free(entries);
var off: usize = overlay_magic.len + 2;
for (entries) |*entry| {
if (bytes.len - off < 3) return error.Corrupt;
const flag = bytes[off];
if (flag > 1) return error.Corrupt;
const path_len = std.mem.readInt(u16, bytes[off + 1 ..][0..2], .little);
off += 3;
if (path_len == 0 or bytes.len - off < path_len) return error.Corrupt;
entry.path = bytes[off..][0..path_len];
off += path_len;
if (flag == 1) {
if (bytes.len - off < 1) return error.Corrupt;
const hash_len = bytes[off];
off += 1;
if (hash_len == 0 or bytes.len - off < hash_len) return error.Corrupt;
entry.hash = bytes[off..][0..hash_len];
off += hash_len;
if (bytes.len - off < 1) return error.Corrupt;
const perm_len = bytes[off];
off += 1;
if (bytes.len - off < perm_len) return error.Corrupt;
entry.perm = if (perm_len == 0) null else bytes[off..][0..perm_len];
off += perm_len;
} else {
entry.hash = null;
entry.perm = null;
}
}
if (off != bytes.len) return error.Corrupt; // no residue
return entries;
}
pub const MergeEntry = struct {
path: []const u8,
action: MergeAction,
};
/// Journal-armed, transactional materialization of a computed merge outcome set,
/// then the new `.zit-managed` and `.zit-checkout`. Order (B-merge-3): (1)
/// journal the pre-images of every affected path ∪ the current checkout ∪ the
/// current managed set, fsynced before the first byte moves; (2) write the new
/// checkout (the caller sets `merge_parent` to the other side's hash) and the
/// new managed set — the pending merge and its membership are recorded FIRST so
/// a crash mid-apply still names them for `zit undo`; (3) materialize each
/// entry. Any failure during (3) rolls files, the managed set AND the checkout
/// back to the pre-merge state and drops the journal, exactly as `applyCheckin`
/// rolls back.
///
/// The managed-set mutation is DERIVED here from the same `entries` stream that
/// drives the filesystem effects, never passed in alongside it: a `.write`
/// registers its path (insert is idempotent, so an ordinary edit-merge is a
/// no-op) and a `.delete` deregisters it. One stream, one truth — a merge that
/// adds a file cannot leave it untracked (blocker B5) and a merge that deletes
/// one cannot leave a phantom "missing" entry behind.
pub fn applyMerge(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
entries: []const MergeEntry,
overlay_bytes: ?[]const u8,
new_checkout: checkout.CheckoutState,
) ApplyError!void {
for (entries) |entry| try validateWorktreePath(entry.path);
// Sparse-cone RFC section 4.4: the caller resolves out-of-cone changes
// at record level (the overlay) and hands this layer IN-CONE entries
// only. The error remains for a caller that skips that split -- crossing
// here would silently lose the change at the next commit.
{
var stored_cone = try readStoredCone(gpa, io, worktree);
defer if (stored_cone) |*c| c.deinit(gpa);
if (stored_cone) |*c| {
for (entries) |entry| {
if (!c.covers(entry.path)) return error.SparseMergeUnsupported;
}
}
}
// (1) Arm the undo journal before any working byte moves (B-merge-3): the
// pre-mutation bytes of every affected path plus the current
// `.zit-checkout`/`.zit-managed`, fsynced HERE. A device fault aborts
// before mutating — no recoverable pre-image, no mutation.
try recordMergeUndoImage(gpa, io, worktree, entries);
// In-process rollback images: the pre-merge bytes/mode/presence of each
// affected path, plus the pre-merge checkout and managed set, so a mid-apply
// failure restores the tree exactly. The journal remains the crash-recovery
// image.
var images = try captureMergeImages(gpa, io, worktree, entries);
defer images.deinit(gpa);
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const old_checkout = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err);
// Two independent reads rather than one rebuilt copy: the on-disk record
// carries more than the membership (a pending `mv` rename), and rebuilding
// `new` from `old.iterate()` would silently drop it.
var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer old_managed.deinit();
var new_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer new_managed.deinit();
// Both sets are a fresh read of the SAME on-disk `.zit-managed`, so before
// the outcome stream is folded into `new_managed` they hold identical
// membership — `old_managed` is the untouched pre-merge baseline the
// rollback restores to.
std.debug.assert(new_managed.iterate().len == old_managed.iterate().len);
try applyMergeMembership(&new_managed, entries);
// (2) Record the pending merge (checkout carries merge_parent = other) and
// the membership it implies in ONE container write — a failure here has
// moved no working byte and left no half-recorded merge, but the
// journal is already armed, so roll it back.
{
const mbuf = gpa.alloc(u8, new_managed.bytes) catch return error.OutOfMemory;
defer gpa.free(mbuf);
var merge_ck_buf: [checkout.max_ck_payload]u8 = undefined;
checkout.updateSections(io, worktree, &.{
.{ .tag = .managed, .payload = new_managed.encode(mbuf) },
.{ .tag = .checkout, .payload = checkout.encodeCk(&merge_ck_buf, new_checkout) },
// Sparse-cone RFC section 4.4: the resolved out-of-cone outcomes
// ride the SAME splice as the pending-merge record -- the pair is
// never observable half-updated, and a merge with no overlay
// clears any stale one an undone earlier merge left behind.
.{ .tag = .merge_overlay, .payload = overlay_bytes },
}) catch |err| {
journal.clear(io, worktree);
return mapCheckoutError(err);
};
}
// (3) Materialize the outcome set; roll files + managed + checkout back on
// any failure.
materializeMergeEntries(io, worktree, entries) catch |err| {
rollbackMerge(io, worktree, images.items, old_checkout, &old_managed) catch {
// The tree is half-restored; the undo journal is the only complete
// recovery image, so retain it and report the failed inverse.
return error.RollbackFailed;
};
// Clean rollback: the tree is back to pre-merge, so drop the journal —
// a cleanly-abandoned merge leaves no spurious undo level (B-merge-3).
journal.clear(io, worktree);
return err;
};
}
/// Folds the merge outcome set into `set`'s membership: every `.write` path is
/// inserted (idempotent — an ordinary edit-merge changes nothing) and every
/// `.delete` path removed (absent is a no-op). No I/O.
fn applyMergeMembership(set: *managed.ManagedSet, entries: []const MergeEntry) ApplyError!void {
for (entries) |entry| switch (entry.action) {
.write => _ = set.insert(entry.path) catch |err| return mapManagedError(err),
.delete => _ = set.remove(entry.path),
};
}
/// Build the affected-path set (deduplicated) and hand it to the undo journal.
fn recordMergeUndoImage(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
entries: []const MergeEntry,
) ApplyError!void {
var affected = managed.ManagedSet.init(gpa);
defer affected.deinit();
for (entries) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err);
journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage, error.Corrupt, error.Empty => return error.Storage,
};
}
const MergeImage = struct {
/// Borrows the caller's `entry.path`, which outlives this call.
path: []const u8,
present: bool,
mode: artifact.FileMode,
/// Pre-merge bytes when `present`; `gpa`-owned. Empty otherwise.
blob: []const u8,
};
const MergeImages = struct {
items: []MergeImage,
fn deinit(self: *MergeImages, gpa: std.mem.Allocator) void {
for (self.items) |img| if (img.present) gpa.free(img.blob);
gpa.free(self.items);
self.* = undefined;
}
};
fn captureMergeImages(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
entries: []const MergeEntry,
) ApplyError!MergeImages {
const items = gpa.alloc(MergeImage, entries.len) catch return error.OutOfMemory;
var filled: usize = 0;
errdefer {
for (items[0..filled]) |img| if (img.present) gpa.free(img.blob);
gpa.free(items);
}
for (entries) |entry| {
items[filled] = try captureMergeImage(gpa, io, worktree, entry.path);
filled += 1;
}
// Exactly one rollback pre-image per affected path: the roll-back walk below
// must be able to restore every path the merge is about to touch, so the
// image count and the outcome-set length can never diverge.
std.debug.assert(filled == entries.len);
return .{ .items = items };
}
fn captureMergeImage(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!MergeImage {
const absent = MergeImage{ .path = path, .present = false, .mode = .regular, .blob = "" };
const existing = try statExisting(io, worktree, path);
const stat = switch (existing) {
.missing => return absent,
.stat => |value| value,
};
if (stat.kind != .file) return absent;
const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
error.PathMissing => return absent,
else => return @errorCast(err),
};
defer parent.close(io);
var file = parent.dir.openFile(io, parent.name, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
}) catch return error.Storage;
defer file.close(io);
const blob = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
errdefer gpa.free(blob);
const n = file.readPositionalAll(io, blob, 0) catch return error.Storage;
if (n != blob.len) return error.Storage;
const mode: artifact.FileMode = if (statIsExecutable(stat)) .executable else .regular;
return .{ .path = path, .present = true, .mode = mode, .blob = blob };
}
fn materializeMergeEntries(io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry) ApplyError!void {
for (entries, 0..) |entry, i| {
// Deterministic mid-apply fault (tests only): the first entry is applied,
// then this fires, so rollback must restore a partially-mutated tree.
if (i == 1 and shouldInjectTestFault(.merge_mid_apply)) return error.Storage;
switch (entry.action) {
.write => |w| {
const mode = try modeFromPerm(w.perm);
try writePayload(io, worktree, entry.path, w.bytes, mode, false, null, true, null);
},
.delete => removeFile(io, worktree, entry.path) catch |err| switch (err) {
error.SymlinkUnsupported => return error.SymlinkUnsupported,
error.Storage => return error.Storage,
},
}
}
}
fn rollbackMerge(
io: std.Io,
worktree: std.Io.Dir,
images: []const MergeImage,
old_checkout: ?checkout.CheckoutState,
old_managed: *const managed.ManagedSet,
) ApplyError!void {
for (images) |img| {
if (img.present) {
restoreFile(io, worktree, img.path, img.blob, img.mode) catch return error.Storage;
} else {
removeFile(io, worktree, img.path) catch return error.Storage;
}
}
// The membership mutation is part of the merge, so its inverse is part of
// the rollback: a rolled-back merge must leave nothing registered that it
// registered, and nothing deregistered that it removed. One container
// write restores both records (an absent pre-merge checkout removes the
// section).
const mbuf = old_managed.gpa.alloc(u8, old_managed.bytes) catch return error.OutOfMemory;
defer old_managed.gpa.free(mbuf);
var ck_buf: [checkout.max_ck_payload]u8 = undefined;
const ck_payload: ?[]const u8 = if (old_checkout) |ck| checkout.encodeCk(&ck_buf, ck) else null;
checkout.updateSections(io, worktree, &.{
.{ .tag = .managed, .payload = old_managed.encode(mbuf) },
.{ .tag = .checkout, .payload = ck_payload },
}) catch |err| return mapCheckoutError(err);
}
/// Maps a fossil-style perm token (the current side's attribute) to a file mode:
/// null/"" = regular, "x" = executable, "l" = symlink (rejected downstream as
/// `SymlinkUnsupported`). Any other token is a malformed outcome (`Corrupt`).
fn modeFromPerm(perm: ?[]const u8) ApplyError!artifact.FileMode {
const token = perm orelse return .regular;
if (token.len == 0) return .regular;
if (std.mem.eql(u8, token, "x")) return .executable;
if (std.mem.eql(u8, token, "l")) return .symlink;
return error.Corrupt;
}
fn setExecutable(io: std.Io, file: std.Io.File, executable: bool) ApplyError!void {
if (comptime !std.Io.File.Permissions.has_executable_bit) return;
const st = file.stat(io) catch return error.Storage;
const old = st.permissions.toMode();
const new = if (executable) old | ((old & 0o444) >> 2) else old & ~@as(@TypeOf(old), 0o111);
if (new == old) return;
file.setPermissions(io, .fromMode(new)) catch return error.Storage;
}
fn statIsExecutable(stat: std.Io.File.Stat) bool {
if (comptime std.Io.File.Permissions.has_executable_bit) {
return stat.permissions.toMode() & 0o111 != 0;
}
return false;
}
fn statExisting(io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath {
return statExistingCached(null, io, root, path);
}
/// `statExisting` through an optional `ParentCache` -- the plan walks pass one
/// so a path-sorted plan stats its entries from one open parent handle.
fn statExistingCached(cache: ?*ParentCache, io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath {
const parent = (if (cache) |c|
openParentCached(c, io, root, path, false, null)
else
openParentNoFollow(io, root, path, false, null)) catch |err| switch (err) {
error.PathMissing => return .missing,
else => return @errorCast(err),
};
defer parent.close(io);
const stat = parent.dir.statFile(io, parent.name, .{ .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => return .missing,
else => return error.Storage,
};
return .{ .stat = stat };
}
fn openParentNoFollow(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
create: bool,
created_directories: ?*CreatedDirectories,
) ParentError!OpenedParent {
var current = root;
var owns_current = false;
errdefer if (owns_current) current.close(io);
var start: usize = 0;
while (std.mem.indexOfScalarPos(u8, path, start, '/')) |slash| {
const component = path[start..slash];
const next = current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => if (create) blk: {
var created = true;
current.createDir(io, component, .default_dir) catch |create_err| switch (create_err) {
error.PathAlreadyExists => created = false,
else => return error.Storage,
};
if (created) if (created_directories) |directories| directories.record(path[0..slash]);
break :blk current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
} else return error.PathMissing,
else => return error.Storage,
};
if (owns_current) current.close(io);
current = next;
owns_current = true;
start = slash + 1;
}
return .{ .dir = current, .name = path[start..], .owned = owns_current };
}
fn directoryContainsOnlyManaged(
gpa: std.mem.Allocator,
io: std.Io,
root: std.Io.Dir,
path: []const u8,
current_managed: *const managed.ManagedSet,
) ApplyError!bool {
const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
var dir = parent.dir.openDir(io, parent.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
defer dir.close(io);
return directoryEntriesAreManaged(gpa, io, dir, path, current_managed);
}
fn directoryEntriesAreManaged(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
prefix: []const u8,
current_managed: *const managed.ManagedSet,
) ApplyError!bool {
var iterator = dir.iterate();
while (iterator.next(io) catch return error.Storage) |entry| {
const full = joinPath(gpa, prefix, entry.name) catch return error.OutOfMemory;
defer gpa.free(full);
switch (entry.kind) {
.file, .sym_link => if (!current_managed.contains(full)) return false,
.directory => {
var child = dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
defer child.close(io);
if (!try directoryEntriesAreManaged(gpa, io, child, full, current_managed)) return false;
},
else => return false,
}
}
return true;
}
fn joinPath(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]u8 {
const out = try gpa.alloc(u8, prefix.len + 1 + name.len);
@memcpy(out[0..prefix.len], prefix);
out[prefix.len] = '/';
@memcpy(out[prefix.len + 1 ..], name);
return out;
}
fn createTransaction(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError![]const u8 {
var random: [12]u8 = undefined;
var encoded: [64]u8 = undefined;
var attempt: usize = 0;
while (attempt < 32) : (attempt += 1) {
io.randomSecure(&random) catch return error.Storage;
const suffix = encodeName(&random, &encoded);
// Recomputed for the prefix, not guessed: 13-byte prefix + the 24-hex
// suffix of a 12-byte nonce.
var name: [".zit-tmp-txn-".len + 24]u8 = undefined;
const prefix = ".zit-tmp-txn-";
@memcpy(name[0..prefix.len], prefix);
@memcpy(name[prefix.len .. prefix.len + suffix.len], suffix);
// Allocate the caller-owned name before the filesystem side effect:
// an ordinary allocation failure must leave no transaction directory.
const owned_name = gpa.dupe(u8, name[0 .. prefix.len + suffix.len]) catch return error.OutOfMemory;
worktree.createDir(io, owned_name, .default_dir) catch |err| {
gpa.free(owned_name);
switch (err) {
error.PathAlreadyExists => continue,
else => return error.Storage,
}
};
return owned_name;
}
return error.TransactionUnavailable;
}
pub fn validateWorktreePath(path: []const u8) ApplyError!void {
return control_paths.validateWorktreePath(path);
}
fn isPathPrefix(prefix: []const u8, path: []const u8) bool {
return path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/';
}
/// The largest working file a carrying update will merge. A path over the cap
/// is `.unsupported`, not silently truncated — the same posture as every other
/// content read that has a ceiling.
pub const max_carry_bytes: usize = 1 << 30;
/// What a working path holds, for the purpose of carrying it across an update.
pub const WorktreeContent = union(enum) {
/// Nothing is at this path.
absent,
/// A regular (or executable) file, read whole.
content: Carry.Content,
/// Something a three-way merge has no bytes for: a symlink, a directory, a
/// device node, or a file past `max_carry_bytes`. The caller names the path
/// and refuses rather than guessing which side should win a type change.
unsupported,
};
/// Read `path` from the working tree as merge input. The single place that
/// decides a worktree file's `FileMode`, so it cannot drift from
/// `modeMatches`, which decides whether that same mode is clean.
pub fn readWorktreeContent(
arena: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
path: []const u8,
) ApplyError!WorktreeContent {
const st = worktree.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound, error.NotDir => return .absent,
else => return error.Storage,
};
if (st.kind != .file) return .unsupported;
if (st.size > max_carry_bytes) return .unsupported;
const executable = if (comptime std.Io.File.Permissions.has_executable_bit)
st.permissions.toMode() & 0o111 != 0
else
false;
const bytes = worktree.readFileAlloc(io, path, arena, .limited(max_carry_bytes)) catch |err| switch (err) {
error.FileNotFound, error.NotDir => return .absent,
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
};
return .{ .content = .{
.bytes = bytes,
.mode = if (executable) .executable else .regular,
} };
}
fn modeMatches(stat: std.Io.File.Stat, mode: artifact.FileMode) bool {
if (comptime std.Io.File.Permissions.has_executable_bit) {
const executable = stat.permissions.toMode() & 0o111 != 0;
return switch (mode) {
.regular => !executable,
.executable => executable,
.symlink => false,
};
}
return mode != .symlink;
}
fn decodeValidatedName(name: []const u8, out: *[logstore.max_hash_len]u8) ApplyError![]const u8 {
if (!artifact.validateName(name)) return error.InvalidCheckinId;
const len = name.len / 2;
var i: usize = 0;
while (i < len) : (i += 1) {
out[i] = (hexNibble(name[i * 2]) << 4) | hexNibble(name[i * 2 + 1]);
}
return out[0..len];
}
fn hexNibble(c: u8) u8 {
return if (c >= '0' and c <= '9') c - '0' else c - 'a' + 10;
}
fn encodeName(raw: []const u8, out: *[64]u8) []const u8 {
const alphabet = "0123456789abcdef";
for (raw, 0..) |byte, i| {
out[i * 2] = alphabet[byte >> 4];
out[i * 2 + 1] = alphabet[byte & 0x0f];
}
return out[0 .. raw.len * 2];
}
fn mapContentError(err: logstore.ContentError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
error.Corrupt => error.Corrupt,
};
}
fn mapManagedError(err: managed.ManagedError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
error.Corrupt, error.TooLong, error.TooMany => error.CurrentStateInvalid,
};
}
fn mapCheckoutError(err: checkout.CheckoutError) ApplyError {
return switch (err) {
error.Storage => error.Storage,
error.Corrupt => error.CurrentStateInvalid,
};
}
fn mapTargetParseError(err: artifact.ParseError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.TargetNotManifest,
};
}
fn mapBaselineParseError(err: artifact.ParseError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.BaselineNotManifest,
};
}
const TestBaseline = struct {
files: []const artifact.FCard,
fn resolve(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const self: *const TestBaseline = @ptrCast(@alignCast(context));
if (!std.mem.eql(u8, hash, repeat("b", 40))) return null;
return .{ .baseline_hash = null, .files = self.files };
}
};
fn completeTestArtifact(gpa: std.mem.Allocator, body: []const u8) ![]u8 {
var checksum_buf: [32]u8 = undefined;
const checksum = artifact.selfChecksum(body, &checksum_buf);
return std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ body, checksum });
}
fn putTestArtifact(log: *logstore.LogStore, name: []const u8, bytes: []const u8) !void {
var digest: [logstore.max_hash_len]u8 = undefined;
const raw = try decodeValidatedName(name, &digest);
_ = try log.put(raw, bytes);
}
fn putCompleteTestArtifact(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
body: []const u8,
id_buf: *[64]u8,
) ![]const u8 {
const bytes = try completeTestArtifact(gpa, body);
defer gpa.free(bytes);
const id = artifact.artifactName(.sha1, bytes, id_buf);
try putTestArtifact(log, id, bytes);
return id;
}
fn putTestPayload(
log: *logstore.LogStore,
bytes: []const u8,
id_buf: *[64]u8,
) ![]const u8 {
const id = artifact.artifactName(.sha1, bytes, id_buf);
try putTestArtifact(log, id, bytes);
return id;
}
fn expectFileState(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
path: []const u8,
expected_bytes: []const u8,
expected_mode: artifact.FileMode,
) !void {
const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false });
try std.testing.expectEqual(.file, stat.kind);
try std.testing.expect(modeMatches(stat, expected_mode));
var file = try worktree.openFile(io, path, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
});
defer file.close(io);
const bytes = try gpa.alloc(u8, stat.size);
defer gpa.free(bytes);
const n = try file.readPositionalAll(io, bytes, 0);
try std.testing.expectEqual(expected_bytes.len, n);
try std.testing.expectEqualStrings(expected_bytes, bytes);
}
fn expectSymlinkTarget(io: std.Io, worktree: std.Io.Dir, path: []const u8, expected_target: []const u8) !void {
const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false });
try std.testing.expectEqual(.sym_link, stat.kind);
var target: [1024]u8 = undefined;
const n = try worktree.readLink(io, path, &target);
try std.testing.expectEqualStrings(expected_target, target[0..n]);
}
const RootEntrySet = struct {
names: std.ArrayList([]u8) = .empty,
fn deinit(self: *RootEntrySet, gpa: std.mem.Allocator) void {
for (self.names.items) |name| gpa.free(name);
self.names.deinit(gpa);
self.* = undefined;
}
fn contains(self: *const RootEntrySet, name: []const u8) bool {
for (self.names.items) |candidate| {
if (std.mem.eql(u8, candidate, name)) return true;
}
return false;
}
};
fn snapshotRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) !RootEntrySet {
var entries = RootEntrySet{};
errdefer entries.deinit(gpa);
var root = try worktree.openDir(io, ".", .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false });
defer root.close(io);
var iterator = root.iterate();
while (try iterator.next(io)) |entry| {
const name = try gpa.dupe(u8, entry.name);
entries.names.append(gpa, name) catch |err| {
gpa.free(name);
return err;
};
}
return entries;
}
fn expectSameRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, expected: *const RootEntrySet) !void {
var actual = try snapshotRootEntries(gpa, io, worktree);
defer actual.deinit(gpa);
try std.testing.expectEqual(expected.names.items.len, actual.names.items.len);
for (expected.names.items) |name| try std.testing.expect(actual.contains(name));
}
fn setupMutatingFailureFixture(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
target_id_buf: *[64]u8,
old_id_buf: *[64]u8,
) ![]const u8 {
var old_payload_id_buf: [64]u8 = undefined;
const old_payload_id = try putTestPayload(log, "old executable bytes", &old_payload_id_buf);
var target_payload_id_buf: [64]u8 = undefined;
const target_payload_id = try putTestPayload(log, "new regular bytes", &target_payload_id_buf);
const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF old-file {s} x\n", .{old_payload_id});
defer gpa.free(old_body);
const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);
const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF new-file {s}\n", .{target_payload_id});
defer gpa.free(target_body);
const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);
try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" });
var old_managed = managed.ManagedSet.init(gpa);
defer old_managed.deinit();
_ = try old_managed.insert("old-file");
try old_managed.write(io, worktree);
try writePayload(io, worktree, "old-file", "old executable bytes", .executable, true, null, true, null);
return target_id;
}
fn setupPathTransitionFixture(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
old_path: []const u8,
target_path: []const u8,
target_id_buf: *[64]u8,
old_id_buf: *[64]u8,
) ![]const u8 {
var old_payload_id_buf: [64]u8 = undefined;
const old_payload_id = try putTestPayload(log, "old bytes", &old_payload_id_buf);
var target_payload_id_buf: [64]u8 = undefined;
const target_payload_id = try putTestPayload(log, "target bytes", &target_payload_id_buf);
const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF {s} {s}\n", .{ old_path, old_payload_id });
defer gpa.free(old_body);
const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);
const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF {s} {s}\n", .{ target_path, target_payload_id });
defer gpa.free(target_body);
const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);
try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" });
var old_managed = managed.ManagedSet.init(gpa);
defer old_managed.deinit();
_ = try old_managed.insert(old_path);
try old_managed.write(io, worktree);
try writePayload(io, worktree, old_path, "old bytes", .regular, true, null, true, null);
return target_id;
}
test "update transaction creation leaves no directory on name allocation failure" {
const t = std.testing;
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
defer entries_before.deinit(t.allocator);
var failing_allocator = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0 });
try t.expectError(error.OutOfMemory, createTransaction(failing_allocator.allocator(), io, tmp.dir));
try t.expect(failing_allocator.has_induced_failure);
try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
}
test "update rollback removes created ancestors before restoring an old file" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupPathTransitionFixture(
t.allocator,
io,
tmp.dir,
&log,
"a",
"a/b/c",
&target_id_buf,
&old_id_buf,
);
var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
defer entries_before.deinit(t.allocator);
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));
try expectFileState(t.allocator, io, tmp.dir, "a", "old bytes", .regular);
try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("a"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(old_id_buf[0..40], state.version);
try t.expectEqualStrings("old-branch", state.branch);
}
test "update replaces managed directory hierarchy with a file" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupPathTransitionFixture(
t.allocator,
io,
tmp.dir,
&log,
"a/b/c",
"a",
&target_id_buf,
&old_id_buf,
);
try applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit);
try expectFileState(t.allocator, io, tmp.dir, "a", "target bytes", .regular);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("a"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(target_id, state.version);
try t.expectEqualStrings("trunk", state.branch);
}
test "update restores mutated bytes mode managed set and checkout after pre-commit failure" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));
try expectFileState(t.allocator, io, tmp.dir, "old-file", "old executable bytes", .executable);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "new-file", .{ .follow_symlinks = false }));
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("old-file"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(old_id_buf[0..40], state.version);
try t.expectEqualStrings("old-branch", state.branch);
}
test "update rolls a materialized symlink back after a later failure" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var old_payload_id_buf: [64]u8 = undefined;
const old_payload_id = try putTestPayload(&log, "old regular bytes\n", &old_payload_id_buf);
var target_payload_id_buf: [64]u8 = undefined;
const target_payload_id = try putTestPayload(&log, "../new-target", &target_payload_id_buf);
const old_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF entry {s}\n", .{old_payload_id});
defer t.allocator.free(old_body);
var old_id_buf: [64]u8 = undefined;
const old_id = try putCompleteTestArtifact(t.allocator, &log, old_body, &old_id_buf);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF entry {s} l\n", .{target_payload_id});
defer t.allocator.free(target_body);
var target_id_buf: [64]u8 = undefined;
const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);
try checkout.write(io, tmp.dir, .{ .version = old_id, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
var old_managed = managed.ManagedSet.init(t.allocator);
defer old_managed.deinit();
_ = try old_managed.insert("entry");
try old_managed.write(io, tmp.dir);
try writePayload(io, tmp.dir, "entry", "old regular bytes\n", .regular, true, null, true, null);
var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
defer entries_before.deinit(t.allocator);
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));
try expectFileState(t.allocator, io, tmp.dir, "entry", "old regular bytes\n", .regular);
try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("entry"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(old_id, state.version);
}
test "update retains the committed checkout after post-checkout cleanup failure" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);
test_fault = .before_cleanup;
defer test_fault = null;
try t.expectError(error.CleanupFailed, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit, .inherit));
try expectFileState(t.allocator, io, tmp.dir, "new-file", "new regular bytes", .regular);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "old-file", .{ .follow_symlinks = false }));
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("new-file"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(target_id, state.version);
try t.expectEqualStrings("trunk", state.branch);
}
test "update delta expansion skips unmatched delete markers" {
const files = [_]artifact.FCard{
.{ .filename = "gone", .hash = null, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "kept", .hash = repeat("a", 40), .perm = null, .old_name = null, .mode = .regular },
};
var baseline = TestBaseline{ .files = &.{} };
var walk = try artifact.enumerate(.{ .baseline_hash = repeat("b", 40), .files = &files }, .{
.ctx = @ptrCast(&baseline),
.resolveFn = TestBaseline.resolve,
});
var materialized: usize = 0;
while (walk.next()) |file| {
if (file.hash == null) continue;
materialized += 1;
try std.testing.expectEqualStrings("kept", file.filename);
}
try std.testing.expectEqual(@as(usize, 1), materialized);
}
// ---------------------------------------------------------------------------
// draft-fossil-submanifests-00 §5 — `update` resolves through commit's resolver
// ---------------------------------------------------------------------------
//
// The Appendix A.2/A.3 vectors, byte-for-byte, stored as real artifacts so the
// shared §5.2 resolver does the resolving over the LogStore this module holds.
// `buildPlan` is the seam: it is the whole read side of `applyCheckin`, and it
// needs no worktree, so these pin the walk without a materialization fixture.
const sm_a2_1_leaf = rfc_vectors.submanifest_a2_1_leaf;
const sm_a2_2_leaf = rfc_vectors.submanifest_a2_2_leaf;
const sm_a3_manifest = rfc_vectors.submanifest_a3_tree_manifest;
/// §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c,
/// src/node11.c, src/util.c: the same list a flat manifest would carry."
const sm_a1_filenames = [_][]const u8{ "README.md", "src/main.c", "src/node11.c", "src/util.c" };
/// Store `bytes` under the name they actually hash to, in the 64-hex form the
/// Appendix A vectors' S cards use.
fn putVectorArtifact(log: *logstore.LogStore, bytes: []const u8, id_buf: *[64]u8) ![]const u8 {
const id = artifact.artifactName(.sha3_256, bytes, id_buf);
try putTestArtifact(log, id, bytes);
return id;
}
/// Respell every S card's hash token in UPPERCASE and reseal the Z card. §3.5
/// admits both spellings — "exactly 40 or 64 case-insensitive hexadecimal
/// characters" — so the result is a legal tree manifest naming the very same
/// two sub-manifests.
fn upperCaseSCardHashes(gpa: std.mem.Allocator, manifest_bytes: []const u8) ![]u8 {
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
var lines = std.mem.splitScalar(u8, manifest_bytes, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
if (line[0] == 'Z') continue; // resealed below over the rewritten body
if (line[0] == 'S') {
const gap = std.mem.lastIndexOfScalar(u8, line, ' ').?;
try body.appendSlice(gpa, line[0 .. gap + 1]);
for (line[gap + 1 ..]) |c| try body.append(gpa, std.ascii.toUpper(c));
} else {
try body.appendSlice(gpa, line);
}
try body.append(gpa, '\n');
}
return completeTestArtifact(gpa, body.items);
}
// §3.5 makes an S card's hash case-insensitive, and `parseSCard` admits both
// spellings on purpose. `update` used to carry its own resolver, which decoded
// the token through `validateName` — canonical lowercase only — and so refused
// a tree that `commit`'s resolver walks without complaint: a legal artifact
// turned into a spurious §5.2 hard failure. Routing both through one resolver
// is what removed that; this pins it so a second copy cannot reintroduce it.
test "update submanifests §3.5: an uppercase S-card hash resolves, as commit's resolver already does" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var leaf_1_buf: [64]u8 = undefined;
_ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf);
var leaf_2_buf: [64]u8 = undefined;
_ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);
const upper = try upperCaseSCardHashes(t.allocator, sm_a3_manifest);
defer t.allocator.free(upper);
// The rewrite really did change the spelling and nothing else about the run.
try t.expect(!std.mem.eql(u8, upper, sm_a3_manifest));
try t.expect(std.mem.indexOf(u8, upper, "S README.md AA9D404C") != null);
var tree_buf: [64]u8 = undefined;
const tree_id = try putVectorArtifact(&log, upper, &tree_buf);
var plan = try buildPlan(t.allocator, &log, tree_id, null);
defer plan.deinit(t.allocator);
try t.expectEqual(sm_a1_filenames.len, plan.entries.items.len);
for (sm_a1_filenames, plan.entries.items) |want, entry| {
try t.expectEqualStrings(want, entry.path);
}
}
// The reverse guarantee of the same unification: routing through the shared
// resolver must not blur a real store fault into §5.2's resolution gap. The
// log source renames `Storage` to `CannotRead` on the way out and `flattenIfTree`
// renames it back, so every code an `ApplyError` consumer could branch on
// survives the trip. Corrupt is the one with an observable trigger: bytes
// stored under a name they do not hash to.
test "update submanifests §5.2: a sub-manifest stored under another name is Corrupt, not a resolution gap" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
// The first S card's target is present and parses — but the bytes filed
// under its name are the *other* leaf's, so the name does not describe them.
var leaf_1_buf: [64]u8 = undefined;
const leaf_1_id = artifact.artifactName(.sha3_256, sm_a2_1_leaf, &leaf_1_buf);
try putTestArtifact(&log, leaf_1_id, sm_a2_2_leaf);
var leaf_2_buf: [64]u8 = undefined;
_ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);
var tree_buf: [64]u8 = undefined;
const tree_id = try putVectorArtifact(&log, sm_a3_manifest, &tree_buf);
// Corrupt, not BaselineNotFound: §5.2's hard failure is about resolution,
// and this repository's integrity is the actual claim being made.
try t.expectError(error.Corrupt, buildPlan(t.allocator, &log, tree_id, null));
}
test "update bootstrap clean state leaves checkout metadata absent" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
var plan = try requireClean(t.allocator, io, tmp.dir, &log, ¤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 the shared reserved root policy before staging" {
const Case = struct { path: []const u8, reserved: bool };
const cases = [_]Case{
.{ .path = ".zit", .reserved = true },
.{ .path = ".ZiT-Checkout", .reserved = true },
.{ .path = ".ZiT-CaChE/repo", .reserved = true },
.{ .path = ".zit-tmp-txn-user/file", .reserved = true },
.{ .path = ".ZiT-Tmp-anything", .reserved = true },
.{ .path = ".zit-checkout-user/file", .reserved = false },
.{ .path = "sub/.zit/object", .reserved = false },
// The deleted legacy tier: ordinary admissible names now.
.{ .path = ".zIT-Managed/entries", .reserved = false },
.{ .path = ".ZiT-VStat/data", .reserved = false },
.{ .path = ".zit-import-map.jsonl/sidecar", .reserved = false },
.{ .path = "ReFs/heads/main", .reserved = false },
.{ .path = ".zit-update-txn-user/file", .reserved = false },
.{ .path = ".zit-update-txn", .reserved = false },
.{ .path = ".zit-update-txns/user", .reserved = false },
};
for (cases) |case_| {
if (case_.reserved) {
try std.testing.expectError(error.ReservedPath, validateWorktreePath(case_.path));
} else {
try validateWorktreePath(case_.path);
}
}
}
test "update reserved paths leave the plan unchanged" {
var plan: Plan = .{};
defer plan.deinit(std.testing.allocator);
const paths = [_][]const u8{ ".ZiT-Checkout", ".zit-tmp-txn-user/file" };
for (paths) |path| {
const file = artifact.FCard{
.filename = path,
.hash = repeat("a", 40),
.perm = null,
.old_name = null,
.mode = .regular,
};
try std.testing.expectError(error.ReservedPath, plan.add(std.testing.allocator, file));
try std.testing.expectEqual(@as(usize, 0), plan.entries.items.len);
}
}
test "update rejects malformed worktree paths before staging" {
try std.testing.expectError(error.InvalidPath, validateWorktreePath("../outside"));
try std.testing.expectError(error.InvalidPath, validateWorktreePath("a//b"));
}
test "update state assembly rejects a manifest stored under another name" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
const misnamed_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF other {s}\n", .{repeat("b", 40)});
defer t.allocator.free(misnamed_body);
const misnamed_bytes = try completeTestArtifact(t.allocator, misnamed_body);
defer t.allocator.free(misnamed_bytes);
var wrong_name_buf: [64]u8 = undefined;
const wrong_name = artifact.artifactName(.sha1, "a different artifact", &wrong_name_buf);
try putTestArtifact(&log, wrong_name, misnamed_bytes);
try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}
test "update state assembly rejects a control stored under another name" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
const control_bytes = try completeTestArtifact(t.allocator, "D 2007-07-21T14:10:00\nT +branch cccccccccccccccccccccccccccccccccccccccc trunk\nU alice\n");
defer t.allocator.free(control_bytes);
var wrong_name_buf: [64]u8 = undefined;
const wrong_name = artifact.artifactName(.sha1, "a different control artifact", &wrong_name_buf);
try putTestArtifact(&log, wrong_name, control_bytes);
try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}
// The shape that made `zit update` accuse an intact fossil clone of corruption:
// an anonymous wiki page. It is well-framed and its cards parse, but a wiki
// artifact requires a `U` card and an anonymous edit has none, so classification
// fails with `MissingRequiredCard`. Its bytes still hash to the name it is
// stored under, so the repository is undamaged and the artifact is opaque to
// state: assembly skips it and completes.
//
// This is the non-corpus stand-in for the 67,499-artifact fossil clone that
// surfaced the defect, where the live instance is artifact
// 14dc8dab9792f8f0139dc05d49cd0f5cdc55503f, a 2008 edit of the page
// "RockShoxBugs". Nothing here depends on that corpus or on any wall clock —
// the trigger is a card set, so a generator can emit it.
test "update state assembly skips a stored artifact it cannot classify" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
defer t.allocator.free(target_body);
var target_id_buf: [64]u8 = undefined;
const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);
// D, L, W, Z — no U card, exactly as fossil stores an anonymous wiki edit.
var wiki_id_buf: [64]u8 = undefined;
_ = try putCompleteTestArtifact(
t.allocator,
&log,
"D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n",
&wiki_id_buf,
);
var assembly = try assembleState(t.allocator, &log, target_id);
defer assembly.deinit();
// Skipped, not absorbed: the wiki artifact contributed no state. Assembly
// resolved the target it was asked for, and the unclassifiable artifact is
// not among the crosslinked records.
try t.expect(assembly.state.store.lookup(wiki_id_buf[0..40]) == null);
}
// The other half of the rule, and the reason the arm verifies before it skips.
// Tolerating an artifact this reader declines must not become tolerating one
// whose bytes do not match the name they are stored under. Same wiki card set,
// same classification failure — but stored under a name that is not its hash.
// That is an integrity failure, and it stays fatal.
//
// Without this test the skip above is vacuous: a blanket `continue` on every
// classification failure would pass it and lose the corruption signal entirely.
test "update state assembly still rejects an unclassifiable artifact whose name does not verify" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
defer t.allocator.free(target_body);
var target_id_buf: [64]u8 = undefined;
const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf);
const wiki_bytes = try completeTestArtifact(
t.allocator,
"D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n",
);
defer t.allocator.free(wiki_bytes);
var wrong_name_buf: [64]u8 = undefined;
const wrong_name = artifact.artifactName(.sha1, "not these bytes", &wrong_name_buf);
try putTestArtifact(&log, wrong_name, wiki_bytes);
try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}
test "update §6.3: a lying R card refuses materialization; the truthful one applies (Fossil's default repo-cksum posture)" {
const t = std.testing;
const test_storage = @import("storage.zig");
const io = t.io;
const blob = "content the R card attests\n";
var blob_id_buf: [64]u8 = undefined;
// One fixture builder, two R spellings: the aggregate over the real
// content, and a hex-flipped lie of it.
var truth_hex: [32]u8 = undefined;
{
var agg: artifact.ChecksumAggregator = .{};
agg.update("a.txt", blob);
_ = agg.final(&truth_hex);
}
var lie_hex: [32]u8 = truth_hex;
lie_hex[0] = if (lie_hex[0] == '0') '1' else '0';
for ([2]struct { r: []const u8, ok: bool }{
.{ .r = &lie_hex, .ok = false },
.{ .r = &truth_hex, .ok = true },
}) |case| {
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const blob_id = try putVectorArtifact(&log, blob, &blob_id_buf);
const body = try std.fmt.allocPrint(
t.allocator,
"C r\nD 2024-01-01T00:00:00\nF a.txt {s}\nR {s}\nU u\n",
.{ blob_id, case.r },
);
defer t.allocator.free(body);
const sealed = try completeTestArtifact(t.allocator, body);
defer t.allocator.free(sealed);
var id_buf: [64]u8 = undefined;
const id = try putVectorArtifact(&log, sealed, &id_buf);
if (case.ok) {
try applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit);
try expectFileState(t.allocator, io, tmp.dir, "a.txt", blob, .regular);
} else {
try t.expectError(
error.AggregateChecksumMismatch,
applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit),
);
// Fail-closed means fail-early: nothing was materialized.
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "a.txt", .{}));
}
}
}
test "update: the staging fault survives the plan it came from (end to end)" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
// The tree and its leaves, but NOT the file blobs their F cards name — so
// the plan builds and staging is the first thing that cannot proceed.
var leaf_1_buf: [64]u8 = undefined;
_ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf);
var leaf_2_buf: [64]u8 = undefined;
_ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf);
var root_buf: [64]u8 = undefined;
const root_id = try putVectorArtifact(&log, sm_a3_manifest, &root_buf);
const prior = last_stage_fault;
defer last_stage_fault = prior;
last_stage_fault = null;
const err = applyCheckin(t.allocator, io, tmp.dir, &log, root_id, .{ .set = "" }, .inherit);
try t.expectError(error.FileContentMissing, err);
// The whole point, and what a hand-built fault cannot check: `applyCheckin`
// frees the plan from its own `defer` on the way out, so a fault that
// BORROWED the plan's path would be read here as freed heap. It printed
// exactly that — ten bytes of garbage where a filename belonged — until the
// path was copied instead.
const f = last_stage_fault orelse return error.TestExpectedFault;
const path = f.filePath();
var known = false;
for (sm_a1_filenames) |name| {
if (std.mem.eql(u8, path, name)) known = true;
}
try t.expect(known);
// And the artifact name is a real 64-hex id, not whatever was on the stack.
try t.expectEqual(@as(usize, 64), f.artifactName().len);
for (f.artifactName()) |c| try t.expect(std.ascii.isHex(c));
}
test "update: a staging fault names the file and the artifact, not just 'corrupt'" {
const t = std.testing;
// Narrow by design: this pins the RENDERING and the reason mapping. That
// `stagePlan` reaches it is enforced by the compiler — `FileContentMissing`
// is in the closed `ApplyError` set, so every one of the four verb mappers
// had to grow an arm for it or fail to build.
const prior = last_stage_fault;
defer last_stage_fault = prior;
var f: StageFault = .{ .reason = .not_in_repository };
const name = repeat("a", 64);
@memcpy(f.name[0..64], name);
f.name_len = 64;
// Copied, never borrowed: the plan that owns the real path is freed by
// `applyCheckin`'s defer before any caller renders this.
@memcpy(f.path_buf[0.."src/main.c".len], "src/main.c");
f.path_len = "src/main.c".len;
last_stage_fault = f;
var buf: [512]u8 = undefined;
const msg = stageFaultReason(&buf);
// The path answers "which file", the hash answers "which artifact to go
// look for in the source repository". A bare corruption claim answers
// neither, which is what made this take minutes to even localise.
try t.expect(std.mem.indexOf(u8, msg, "src/main.c") != null);
try t.expect(std.mem.indexOf(u8, msg, name) != null);
try t.expect(std.mem.indexOf(u8, msg, "is not in this repository") != null);
last_stage_fault = null;
const neutral = stageFaultReason(&buf);
try t.expect(std.mem.indexOf(u8, neutral, "missing") != null);
}
test "update state assembly retains the stable MemStore backing RepoState" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{repeat("a", 40)});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
var assembly = try assembleState(t.allocator, &log, target_id);
defer assembly.deinit();
try t.expectEqual(@intFromPtr(assembly.mem), @intFromPtr(assembly.state.store.context));
const added = try assembly.state.crosslink(.{
.hash = repeat("b", 40),
.mtime = 1,
});
try t.expectEqual(added, assembly.mem.store().lookup(repeat("b", 40)).?);
}
fn setupPreMergeWorktree(io: std.Io, worktree: std.Io.Dir) !void {
// A clean checkout with no pending merge, plus two managed files.
try checkout.write(io, worktree, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try writePayload(io, worktree, "keep-me", "current\n", .regular, true, null, true, null);
try writePayload(io, worktree, "remove-me", "gone\n", .regular, true, null, true, null);
}
test "merge applyMerge materializes writes and deletes and records the merge parent" {
const t = std.testing;
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
try setupPreMergeWorktree(io, tmp.dir);
const other = repeat("2", 64);
const entries = [_]MergeEntry{
.{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
.{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } },
.{ .path = "remove-me", .action = .delete },
};
try applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = other, .special_op = "", .manifest_flags = "" });
try expectFileState(t.allocator, io, tmp.dir, "keep-me", "merged\n", .regular);
// B-merge-23: the current-side "x" attribute is preserved on the merged file.
try expectFileState(t.allocator, io, tmp.dir, "tool.sh", "#!/bin/sh\n", .executable);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "remove-me", .{ .follow_symlinks = false }));
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
try t.expectEqualStrings(repeat("1", 64), ck.version);
try t.expectEqualStrings("trunk", ck.branch);
try t.expectEqualStrings(other, ck.merge_parent);
// Journal armed before mutating: the undo level is present (B-merge-3).
const journal_bytes = (try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo)).?;
t.allocator.free(journal_bytes);
}
test "merge applyMerge journals the pre-merge image so undo clears the pending merge" {
const t = std.testing;
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
try setupPreMergeWorktree(io, tmp.dir);
const entries = [_]MergeEntry{
.{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
.{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } },
.{ .path = "remove-me", .action = .delete },
};
try applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = repeat("2", 64), .special_op = "", .manifest_flags = "" });
// The journal captured the pre-merge tree (files + merge_parent == ""), so
// undo restores it byte-for-byte and the pending merge disappears (B-merge-13).
try journal.undo(t.allocator, io, tmp.dir);
try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular);
try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "tool.sh", .{ .follow_symlinks = false }));
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
try t.expectEqualStrings(repeat("1", 64), ck.version);
try t.expectEqualStrings("", ck.merge_parent);
}
test "merge applyMerge rolls files and checkout back on an injected mid-apply failure" {
const t = std.testing;
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
try setupPreMergeWorktree(io, tmp.dir);
// Entry 0 (write) is applied; the fault fires before entry 1 (delete), so the
// tree is partially mutated and rollback must fully restore it.
const entries = [_]MergeEntry{
.{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } },
.{ .path = "remove-me", .action = .delete },
};
test_fault = .merge_mid_apply;
defer test_fault = null;
try t.expectError(error.Storage, applyMerge(t.allocator, io, tmp.dir, &entries, null, .{ .version = repeat("1", 64), .branch = "trunk", .merge_parent = repeat("2", 64), .special_op = "", .manifest_flags = "" }));
// Files restored, no delete applied, checkout back to no-pending-merge.
try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular);
try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular);
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?;
try t.expectEqualStrings(repeat("1", 64), ck.version);
try t.expectEqualStrings("", ck.merge_parent);
// A clean rollback drops the journal — no spurious undo level survives.
const undo_section = try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo);
try t.expectEqual(@as(?[]u8, null), undo_section);
}
// Pull the undo/redo journal's inline unit tests (contracts/undo.md) into
// `zig build test`. `undo.zig` and this file import each other; referencing it
// here in the same module keeps a single instance and no duplicate analysis.
test {
_ = journal;
}
test "update: a legacy trailing-separator F card plans the hash it names" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
// The blob the check-in names, stored under its real sha1 name.
const blob = "int main(void){return 0;}\n";
var blob_id_buf: [64]u8 = undefined;
const blob_id = artifact.artifactName(.sha1, blob, &blob_id_buf);
try putTestArtifact(&log, blob_id, blob);
// A manifest whose F card carries the LEGACY TRAILING SEPARATOR — one SP
// after the hash. This is what fossil-origin history looks like, and it is
// the only shape that makes `parseForImport` normalize into a fresh buffer:
// the manifest then borrows THAT copy, not the stored bytes, and every
// F-card hash the plan reads points into it.
//
// Nothing else in this suite exercises that. Every synthetic corpus is
// git-native and never carries the separator, so `normalized` is null
// throughout — which left the one path a real fossil-origin import always
// takes covered only by repositories nobody runs the tests against.
const body = try std.fmt.allocPrint(
t.allocator,
"D 2007-07-21T14:09:59\nF src/expr.c {s} \n",
.{blob_id},
);
defer t.allocator.free(body);
const bytes = try completeTestArtifact(t.allocator, body);
defer t.allocator.free(bytes);
var id_buf: [64]u8 = undefined;
const id = artifact.artifactName(.sha1, bytes, &id_buf);
try putTestArtifact(&log, id, bytes);
var plan = try buildPlan(t.allocator, &log, id, null);
defer plan.deinit(t.allocator);
try t.expectEqual(@as(usize, 1), plan.entries.items.len);
const entry = plan.entries.items[0];
try t.expectEqualStrings("src/expr.c", entry.path);
// The whole question: does the plan carry the name the F card wrote?
try t.expectEqualStrings(blob_id, entry.name);
}
// --- Foreign check-ins -----------------------------------------------------
// The format types artifacts by STRUCTURE (artifact-format §5.1 L1603), and
// repo-state §4.4 L654-662 exempts only what a parser DECLINES to classify. So
// a stored blob whose bytes are a valid check-in manifest IS a check-in of this
// repository's DAG, even though its content came from somewhere else — a
// Fossil→Git mirror's tracked `manifest` file being the case that found this.
//
// Both tests below assert CONFORMANCE, not a defect: the derivation is required
// to behave this way. What must never regress is what the CONSUMERS do about it.
test "update: a foreign check-in that carries its own branch tag is refused, not materialized" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
// A check-in naming a file whose blob this repository never held — the
// shape a mirror's `manifest` has, since its F cards name the FOREIGN
// repository's artifacts. `T *branch * trunk` is a self-targeted,
// value-carrying propagating tag (§4.1: the UUID field is an artifact name
// or the literal `*`; `T *branch trunk` puts a branch name in the UUID
// field and the whole manifest is rejected instead).
const body = try std.fmt.allocPrint(
t.allocator,
"C foreign\nD 2099-01-01T00:00:00\nF src/expr.c {s}\nT *branch * trunk\nT *sym-trunk *\nU drh\n",
.{repeat("7e", 20)},
);
defer t.allocator.free(body);
const bytes = try completeTestArtifact(t.allocator, body);
defer t.allocator.free(bytes);
var id_buf: [64]u8 = undefined;
const id = artifact.artifactName(.sha1, bytes, &id_buf);
try putTestArtifact(&log, id, bytes);
const prior = last_stage_fault;
defer last_stage_fault = prior;
last_stage_fault = null;
// Materializing it must FAIL, and fail before touching the working tree.
// `stagePlan` verifies and stages every byte into the transaction directory
// before any worktree mutation, which is why a plan naming absent content
// cannot leave a half-written tree behind.
try t.expectError(
error.FileContentMissing,
applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit),
);
const f = last_stage_fault orelse return error.TestExpectedFault;
try t.expectEqualStrings("src/expr.c", f.filePath());
// FAIL-CLOSED, stated as an assertion so it cannot be traded away later:
// nothing the check-in named was written.
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "src/expr.c", .{}));
}
test "update: the refusal explains the class, not just the missing path" {
const t = std.testing;
// A path and a hash alone send someone hunting for a corrupt artifact. The
// cause is that the repository holds a check-in belonging to a different
// repository, which is not guessable from either.
try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "ANOTHER repository") != null);
try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "mirror") != null);
// And it must not allege damage: the store is intact and `verify` will say so.
try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "corrupt") == null);
}
/// A `CarryResolver` for the in-module tests: answers with a fixed carry set.
const FixedCarryResolver = struct {
answer: []const Carry,
fn resolver(self: *FixedCarryResolver) CarryResolver {
return .{ .ctx = self, .resolve = resolve };
}
fn resolve(
ctx: *anyopaque,
divergences: []const Divergence,
) error{ OutOfMemory, Refused }![]const Carry {
_ = divergences;
const self: *FixedCarryResolver = @ptrCast(@alignCast(ctx));
return self.answer;
}
};
test "a carried update rolls back to the WORKING bytes, not the check-in's" {
// The invariant that makes `Carry` name two endpoints instead of one.
//
// A carrying update writes merged bytes over a file the operator has
// uncommitted edits in. If it fails partway, the staged `previous/` tree is
// what gets restored — and if that tree held the CHECK-IN's content (which
// is what the baseline plan describes) the rollback would quietly replace
// uncommitted work with committed bytes. A failed update destroying the
// edits it was asked to preserve is the same class of loss the carry rule exists
// to prevent, and it would leave no trace: the update reports an error, the
// tree looks plausible, and the work is gone.
//
// So `overlayCarry` puts `before` into the baseline plan as well, and this
// is the test that the two endpoints are actually wired to their two
// destinations rather than both to the same one.
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var v1_buf: [64]u8 = undefined;
const v1 = try putTestPayload(&log, "committed base", &v1_buf);
var v2_buf: [64]u8 = undefined;
const v2 = try putTestPayload(&log, "committed target", &v2_buf);
const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1});
defer t.allocator.free(base_body);
var base_buf: [64]u8 = undefined;
const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2});
defer t.allocator.free(target_body);
var target_buf: [64]u8 = undefined;
const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf);
try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit);
// The operator's uncommitted work. Deliberately different from BOTH
// check-ins, so a rollback that restored either one would be caught.
const working_bytes = "uncommitted local work";
try tmp.dir.deleteFile(io, "moving");
{
var f = try tmp.dir.createFile(io, "moving", .{});
defer f.close(io);
try f.writePositionalAll(io, working_bytes, 0);
}
const carry = [_]Carry{.{
.path = "moving",
.after = .{ .bytes = "merged bytes that must not survive" },
.before = .{ .bytes = working_bytes },
}};
var fixed = FixedCarryResolver{ .answer = &carry };
// Fail AFTER the merged bytes have been materialized — the only window in
// which the rollback has real work to undo.
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckinWith(
t.allocator,
io,
tmp.dir,
&log,
target,
.inherit,
.inherit,
null,
fixed.resolver(),
));
// The operator's bytes are back. NOT "committed base" (what the baseline
// plan describes and what a one-endpoint carry would have restored), and
// NOT the merged bytes the failed update wrote.
try expectFileState(t.allocator, io, tmp.dir, "moving", working_bytes, .regular);
// And the checkout did not move, so the failed update is invisible except
// for its error.
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(base, state.version);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("moving"));
}
test "a carried update rolls a locally-added file back to existing, not to absent" {
// The membership half of the same invariant. A path the checkout manages
// but neither check-in has (`zit add`, not yet committed) is carried into
// the TARGET plan so the move does not delete it — which also makes it a
// path the target plan creates. A rollback that reasoned only from the
// target would therefore delete it as "a file this update created",
// destroying an add the operator made before the update ran.
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var v1_buf: [64]u8 = undefined;
const v1 = try putTestPayload(&log, "committed base", &v1_buf);
var v2_buf: [64]u8 = undefined;
const v2 = try putTestPayload(&log, "committed target", &v2_buf);
const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1});
defer t.allocator.free(base_body);
var base_buf: [64]u8 = undefined;
const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2});
defer t.allocator.free(target_body);
var target_buf: [64]u8 = undefined;
const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf);
try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit);
const added_bytes = "a file the operator added but has not committed";
{
var f = try tmp.dir.createFile(io, "added", .{});
defer f.close(io);
try f.writePositionalAll(io, added_bytes, 0);
}
{
var set = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer set.deinit();
_ = try set.insert("added");
try set.write(io, tmp.dir);
}
// Neither side of the merge has an opinion about `added`, so it is carried
// unchanged: same bytes at both endpoints.
const carry = [_]Carry{.{
.path = "added",
.after = .{ .bytes = added_bytes },
.before = .{ .bytes = added_bytes },
}};
var fixed = FixedCarryResolver{ .answer = &carry };
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckinWith(
t.allocator,
io,
tmp.dir,
&log,
target,
.inherit,
.inherit,
null,
fixed.resolver(),
));
// Still there, with its bytes, and still managed.
try expectFileState(t.allocator, io, tmp.dir, "added", added_bytes, .regular);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expect(current_managed.contains("added"));
try t.expect(current_managed.contains("moving"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(base, state.version);
}
test "ensureEmptyDirs creates each listed directory, warns instead of replacing" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
// An empty list touches nothing and says nothing.
ensureEmptyDirs(io, tmp.dir, "", &w);
try std.testing.expectEqualStrings("", w.buffered());
// Two patterns, the tokenizer's whitespace split; parents come with them.
ensureEmptyDirs(io, tmp.dir, "a/b/c tmp/cache", &w);
try std.testing.expectEqualStrings("", w.buffered());
var a = try tmp.dir.openDir(io, "a/b/c", .{});
a.close(io);
var t = try tmp.dir.openDir(io, "tmp/cache", .{});
t.close(io);
// Idempotent: an existing directory is left alone and warns about nothing.
ensureEmptyDirs(io, tmp.dir, "a/b/c", &w);
try std.testing.expectEqualStrings("", w.buffered());
// A file in the way is never replaced — one warning, the file survives.
try tmp.dir.writeFile(io, .{ .sub_path = "occupied", .data = "mine\n" });
ensureEmptyDirs(io, tmp.dir, "occupied", &w);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "'occupied' exists and is not a directory") != null);
const kept = try tmp.dir.readFileAlloc(io, "occupied", std.testing.allocator, .limited(64));
defer std.testing.allocator.free(kept);
try std.testing.expectEqualStrings("mine\n", kept);
// A versioned value must not be able to mint a control name or escape.
w = std.Io.Writer.fixed(&buf);
ensureEmptyDirs(io, tmp.dir, ".zit-tmp-x ../outside", &w);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), ".zit-tmp-x") != null);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "../outside") != null);
try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, ".zit-tmp-x", .{}));
}