//! The F-set composition a check-in is built from: pure value-in/value-out
//! pieces shared by the working-tree commit (`cmd/checkout.zig`) and the
//! direct-commit core (`checkin.zig`), so the two cannot mint different
//! manifests for the same change. No I/O, no clock, no environment.
const std = @import("std");
const artifact = @import("../artifact.zig");
const cone_mod = @import("../cone.zig");
/// Merge policy for the new check-in's F-set.
/// - `.snapshot` : the F-set IS `captured` (the managed set is
/// authoritative; a parent file absent from `captured` is a
/// DELETION and is dropped — B-commit-8/12, all-files
/// commit). `parent` is consulted only for the no-op verdict.
/// - `.carry_forward`: `captured` files, plus every `parent` file whose
/// filename is NOT in `captured`, carried forward at the
/// parent's card unchanged (B-commit-11 partial commit; a
/// root partial has an empty `parent`, so this degrades to
/// `captured`).
pub const ComposePolicy = enum { snapshot, carry_forward };
/// Value inputs to `composeFCards`. `parent` is the parent check-in's F-set
/// (empty for a root); `captured` are the files read + hashed from the working
/// tree this commit; `policy` selects the merge rule above.
pub const ComposeInput = struct {
parent: []const artifact.FCard,
captured: []const artifact.FCard,
policy: ComposePolicy,
/// Sparse-cone RFC section 4.2: when set, a `.snapshot` compose ALSO
/// carries forward every parent card the cone does not cover, byte-
/// verbatim -- the snapshot semantics (deletion by absence) apply only
/// inside the cone. Null = dense, bit-identical to the shipped compose.
/// `.carry_forward` needs no cone: it already carries everything
/// uncaptured, which subsumes the out-of-cone set.
sparse: ?*const cone_mod.Cone = null,
};
/// The composed F-set and the no-op verdict.
pub const ComposeResult = struct {
/// Merged F-set, ascending by filename per §4.5.3 (`artifact.compareBytes`),
/// allocated on the caller's arena. Filenames are unique.
f: []const artifact.FCard,
/// True iff `f` is identical to `parent` on the file-set identity (filename +
/// hash + mode, entrywise, in sorted order) — the child no-op verdict
/// (B-commit-9). For a root (`parent` empty) this is exactly `f.len == 0`
/// ("nothing tracked").
unchanged: bool,
};
/// §4.5.3 ordering predicate: raw-byte ascending by filename (shorter-first on a
/// prefix tie, via `artifact.compareBytes`). Filenames are unique, so a strict
/// `.lt` total order needs no tiebreak.
fn lessByFilename(_: void, a: artifact.FCard, b: artifact.FCard) bool {
return artifact.compareBytes(a.filename, b.filename) == .lt;
}
/// The one genuinely deep, RFC-normative unit of `commit`, extracted pure so it
/// is reusable and unit-tested without a subprocess: it merges already-hashed
/// `FCard` values into the new check-in's F-set and reports the no-op verdict.
/// No I/O, no clock, no env, no hashing — it rearranges values and allocates
/// only the result array on `arena` (value-in/value-out, so it lifts verbatim
/// into a future deep `checkin.compose`). (B-commit-8/9/11/12, §4.5.3.)
///
/// Algorithm: (1) gather `captured`, plus — under `.carry_forward` — every
/// `parent` card whose filename is neither a captured filename nor a captured
/// artifact §4.7.6 old-name provenance. A carried rename consumes that one-shot
/// provenance by clearing `old_name` and its regular-file `w` placeholder;
/// untouched parent files carry forward otherwise, then (2) sort ascending by
/// filename (§4.5.3); (3) in a safety build, drive `artifact.FCardAdmission`
/// over the sorted filenames to assert strict §4.5.2-r1 order with no reserved
/// names — a dup or mis-sort is a bug, never shipped; (4) the no-op verdict
/// compares the result to `parent` entrywise on filename+hash+mode (§4.5.3-
/// sorted parent from a parsed manifest, so the comparison is aligned).
/// Captured rename tokens remain intact; the verdict keys on filename+hash+mode,
/// so a rename changes the filename and is never a no-op.
pub fn composeFCards(arena: std.mem.Allocator, in: ComposeInput) error{OutOfMemory}!ComposeResult {
var list: std.ArrayList(artifact.FCard) = .empty;
errdefer list.deinit(arena);
try list.appendSlice(arena, in.captured);
if (in.policy == .carry_forward or in.sparse != null) {
for (in.parent) |pc| {
if (in.policy == .snapshot) {
// Sparse snapshot: only OUT-OF-CONE parent cards carry; an
// in-cone parent file absent from `captured` is a deletion,
// exactly as in a dense snapshot (RFC section 4.2).
if (in.sparse.?.covers(pc.filename)) continue;
}
var captured_here = false;
for (in.captured) |cc| {
if (std.mem.eql(u8, pc.filename, cc.filename) or
(cc.old_name != null and std.mem.eql(u8, pc.filename, cc.old_name.?)))
{
captured_here = true;
break;
}
}
if (!captured_here) {
var carried = pc;
if (carried.old_name != null) {
carried.old_name = null;
if (carried.mode == .regular and
carried.perm != null and
std.mem.eql(u8, carried.perm.?, "w"))
{
carried.perm = null;
}
}
try list.append(arena, carried);
}
}
}
std.mem.sort(artifact.FCard, list.items, {}, lessByFilename);
// Postcondition (§4.5.2-r1 order + §4.6.3 admission): every card is admitted
// — strictly ascending, no reserved name among managed paths. Safety-gated
// so it costs nothing in ReleaseFast; a violation is a compose bug, not a
// wire condition, so a trip here is correct.
if (std.debug.runtime_safety) {
var adm: artifact.FCardAdmission = .{};
for (list.items) |card| {
const kept = adm.admit(card.filename) catch |e|
std.debug.panic("composeFCards produced out-of-order F cards: {s}", .{@errorName(e)});
std.debug.assert(kept);
}
}
const unchanged = blk: {
if (list.items.len != in.parent.len) break :blk false;
for (list.items, in.parent) |a, b| {
if (!std.mem.eql(u8, a.filename, b.filename)) break :blk false;
if (artifact.compareOptional(a.hash, b.hash) != .eq) break :blk false;
if (a.mode != b.mode) break :blk false;
}
break :blk true;
};
return .{ .f = try list.toOwnedSlice(arena), .unchanged = unchanged };
}
/// The serialized size an F card contributes to a manifest: letter, spaces,
/// tokens, LF. An estimate only in the sense that §4.3 escapes can lengthen
/// a filename — identically in both runs being compared, so the comparison
/// is exact where it matters.
pub fn fCardCost(c: artifact.FCard) usize {
var n: usize = 2 + c.filename.len + 1; // "F " + name + LF
if (c.hash) |h| n += 1 + h.len;
if (c.perm) |p| n += 1 + p.len;
if (c.old_name) |o| n += 1 + o.len;
return n;
}
/// A delta manifest's baseline: its raw F run and its name.
pub const DialectBase = struct {
raw: []const artifact.FCard,
hash: []const u8,
};
/// The baseline a delta manifest built on `parent` would name: the parent
/// when it is flat, or the parent's OWN baseline when it is a delta (§4.7.3:
/// never a delta of a delta). Null when the dialect does not continue: a tree
/// parent (§4.4: B and S never mix), a parent whose baseline is itself a delta
/// (foreign history the writer must not extend), or a flat parent in a
/// repository that has never stored a delta manifest (`seen_delta` false).
/// `baseline` is the parent's parsed baseline, present iff `parent.b` is.
pub fn dialectBase(
parent: *const artifact.Manifest,
baseline: ?*const artifact.Manifest,
parent_hash: []const u8,
seen_delta: bool,
) ?DialectBase {
std.debug.assert((parent.b == null) == (baseline == null));
if (parent.s.len != 0) return null;
if (parent.b) |b| {
if (baseline.?.b != null) return null;
return .{ .raw = baseline.?.f, .hash = b.hash };
}
if (!seen_delta) return null;
return .{ .raw = parent.f, .hash = parent_hash };
}
/// A delta manifest's B card and F run.
pub const DeltaPatch = struct {
b: artifact.BCard,
f: []const artifact.FCard,
};
/// The delta patch of `composed` (the new check-in's complete, sorted F-set)
/// against `base`, or null when the patch would not be smaller than the flat
/// run. A path present in the baseline and absent now becomes a delete
/// marker (an F card with no hash, §4.7.6); a path equal to the baseline's
/// card is omitted.
///
/// The size guard is in BYTES rather than cards (a delete marker is a dozen
/// bytes, a full card ~80 — counting them equal suppresses the dialect on
/// exactly the small patches deltas exist for): a patch whose F run is not
/// smaller than the flat one carries no benefit, so the flat form is kept and
/// the dialect left to the next commit.
pub fn deltaPatch(
arena: std.mem.Allocator,
composed: []const artifact.FCard,
base: DialectBase,
) error{OutOfMemory}!?DeltaPatch {
var cards: std.ArrayList(artifact.FCard) = .empty;
var ci: usize = 0;
var bi: usize = 0;
while (ci < composed.len or bi < base.raw.len) {
const order: std.math.Order = if (ci >= composed.len)
.gt
else if (bi >= base.raw.len)
.lt
else
artifact.compareBytes(composed[ci].filename, base.raw[bi].filename);
switch (order) {
.lt => {
try cards.append(arena, composed[ci]);
ci += 1;
},
.gt => {
try cards.append(arena, .{
.filename = base.raw[bi].filename,
.hash = null,
.perm = null,
.old_name = null,
.mode = .regular,
});
bi += 1;
},
.eq => {
const c = composed[ci];
const bl = base.raw[bi];
const same = c.old_name == null and c.mode == bl.mode and
std.mem.eql(u8, c.hash orelse "", bl.hash orelse "") and
std.mem.eql(u8, c.perm orelse "", bl.perm orelse "");
if (!same) try cards.append(arena, c);
ci += 1;
bi += 1;
},
}
}
var delta_bytes: usize = 0;
for (cards.items) |c| delta_bytes += fCardCost(c);
var flat_bytes: usize = 0;
for (composed) |c| flat_bytes += fCardCost(c);
if (delta_bytes >= flat_bytes) return null;
return .{ .b = .{ .hash = base.hash }, .f = cards.items };
}
test "deltaPatch: a one-card change against a wide baseline is a patch; a rewrite is not" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const h1_buf: [64]u8 = @splat('1');
const h2_buf: [64]u8 = @splat('2');
const h1: []const u8 = &h1_buf;
const h2: []const u8 = &h2_buf;
const base = [_]artifact.FCard{
.{ .filename = "a", .hash = h1, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "b", .hash = h1, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "c", .hash = h1, .perm = null, .old_name = null, .mode = .regular },
};
const now = [_]artifact.FCard{
.{ .filename = "a", .hash = h1, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "b", .hash = h2, .perm = null, .old_name = null, .mode = .regular },
};
const got = (try deltaPatch(arena, &now, .{ .raw = &base, .hash = "base" })).?;
try std.testing.expectEqualStrings("base", got.b.hash);
try std.testing.expectEqual(@as(usize, 2), got.f.len);
try std.testing.expectEqualStrings("b", got.f[0].filename);
try std.testing.expectEqualStrings(h2, got.f[0].hash.?);
try std.testing.expectEqualStrings("c", got.f[1].filename);
try std.testing.expect(got.f[1].hash == null);
const rewrite = [_]artifact.FCard{
.{ .filename = "a", .hash = h2, .perm = null, .old_name = null, .mode = .regular },
};
try std.testing.expect(try deltaPatch(arena, &rewrite, .{ .raw = &base, .hash = "base" }) == null);
}