//! zit checkout / mutating verbs (update, bisect, merge, revert, undo, redo,
//! commit, amend) and the helpers and tests private to them.
//!
//! Extracted verbatim from src/main.zig (release step S5 -- the final family
//! extraction, which completes the split of the CLI surface). Every declaration
//! below is byte-identical to its main.zig source; only this import prologue is
//! new, plus the `pub` the verb-dispatch switch forces on the eight dispatched
//! handlers. Helpers still shared with other verbs stay in main.zig and are
//! aliased back here so the moved bodies call them unqualified. main.zig's
//! verb-dispatch switch calls into this file (`cmd_checkout.runUpdate` ...); its
//! test block imports it so these inline tests keep running under
//! `zig build test`.
const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const merge3 = @import("../merge3.zig");
const cmd_sync = @import("sync.zig");
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const update = @import("zit").update;
const checkout = @import("zit").checkout;
const bisect = @import("zit").bisect;
const Repository = @import("zit").Repository;
const Io = std.Io;
const File = Io.File;
const repostate = zit.repostate;
const managed = zit.managed;
const artifact = zit.artifact;
// Shared CLI helpers, constants, and verb help text that stay in main.zig
// (other verbs use them too, or the dispatch help table references them);
// aliased back so the moved code and tests reference them by their original
// unqualified names.
const main = @import("../main.zig");
const reasonOf = main.reasonOf;
const stateOom = main.stateOom;
const checkoutFail = main.checkoutFail;
const managedFail = main.managedFail;
const repoOpenFail = main.repoOpenFail;
const openRepo = main.openRepo;
const firstCommentLine = main.firstCommentLine;
const lessByHash = main.lessByHash;
const mintControlArtifact = main.mintControlArtifact;
const hexToDigest = main.hexToDigest;
const updateApplyFail = main.updateApplyFail;
const trackedFileSizeAllowed = main.trackedFileSizeAllowed;
const executable = main.executable;
const digest_len = main.digest_len;
const sym_prefix = main.sym_prefix;
const timeline_hash_display = main.timeline_hash_display;
const not_a_repo_msg = main.not_a_repo_msg;
const repo_filename = main.repo_filename;
const max_tracked_file_bytes = main.max_tracked_file_bytes;
const symlink_unversioned = main.symlink_unversioned;
const update_help = main.update_help;
const merge_help = main.merge_help;
const bisect_help = main.bisect_help;
const revert_help = main.revert_help;
const undo_help = main.undo_help;
const redo_help = main.redo_help;
const amend_help = main.amend_help;
const commit_help = main.commit_help;
/// The parent's symbolic tags a branch-root check-in must cancel (repo-state
/// §6.2). §6.2: "Branch creation MUST also cancel every pre-existing active
/// sym-* tag on the branch-root check-in ... because symbolic tags are
/// otherwise INHERITED from a check-in's parent." Inheritance is propagation,
/// so the cancel set is exactly the parent's PROPAGATING (`*`) syms: a singleton
/// (`+`) sym never propagates, so the child does not inherit it and cancelling
/// it would emit a spurious non-canonical `-sym-X` for a tag the child never
/// held. `zit tag add` mints exactly such a singleton sym (REGRESSION B6), so
/// this exclusion is reachable locally now, not only via a synced
/// `T +sym-X *`. The branch's own re-established sym (`want_sym`) is excluded,
/// since `composeCommitTags` re-adds it as a propagating tag. Pure; each
/// surfaced name is duped onto `arena` so it outlives the read view. `entries`
/// come from `RepoState.tagsOf`, which surfaces active rows only (no cancels).
fn selectInheritedSyms(
arena: std.mem.Allocator,
entries: []const repostate.RepoState.TagEntry,
want_sym: []const u8,
) error{OutOfMemory}![]const []const u8 {
std.debug.assert(std.mem.startsWith(u8, want_sym, sym_prefix));
var syms: std.ArrayList([]const u8) = .empty;
errdefer syms.deinit(arena);
for (entries) |e| {
std.debug.assert(e.tagtype != .cancel); // tagsOf surfaces active rows only
if (e.tagtype != .propagate) continue; // §6.2: only inherited (propagating) syms
if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue;
if (std.mem.eql(u8, e.name, want_sym)) continue; // re-established as a propagating tag
try syms.append(arena, try arena.dupe(u8, e.name));
}
return syms.toOwnedSlice(arena);
}
test "commit repo-state §6.2: only inherited (propagating) parent syms are cancelled" {
const gpa = std.testing.allocator;
const TagEntry = repostate.RepoState.TagEntry;
// A parent (as `tagsOf` surfaces it: active rows only) carrying every case:
// an inherited propagating sym (MUST cancel), the branch's own propagating
// sym (excluded — re-established), a SINGLETON sym (MUST NOT cancel; §6.2
// cancels only inherited syms, and a `+` sym never propagates), and a
// non-sym propagating tag (`branch`, not a symbolic name).
const entries = [_]TagEntry{
.{ .name = "sym-trunk", .tagtype = .propagate, .value = null },
.{ .name = "sym-feature", .tagtype = .propagate, .value = null },
.{ .name = "sym-adhoc", .tagtype = .singleton, .value = null },
.{ .name = "branch", .tagtype = .propagate, .value = "trunk" },
};
const got = try selectInheritedSyms(gpa, &entries, "sym-feature");
defer {
for (got) |s| gpa.free(s);
gpa.free(got);
}
// Exactly the inherited propagating sym, minus the re-established one: the
// singleton `sym-adhoc` and the non-sym `branch` are both excluded.
try std.testing.expectEqual(@as(usize, 1), got.len);
try std.testing.expectEqualStrings("sym-trunk", got[0]);
}
/// `zit update CHECKIN` — parse one canonical artifact ID, discover the
/// worktree, open the repository read-only, and delegate the transaction to
/// the private update module.
pub fn runUpdate(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
const parsed = switch (cli.parseUpdateArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "update", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "update", .extra_argument, tok),
.empty_value => |tok| return cli.usageError(streams, "update", .empty_value, tok),
.too_many_cones => |tok| return cli.usageError(streams, "update", .too_many_cones, tok),
.cone_and_dense => return cli.usageError(streams, "update", .cone_and_dense, ""),
};
if (parsed.help) {
streams.out.writeAll(update_help) catch {};
return .ok;
}
const requested = parsed.checkin orelse
return cli.usageError(streams, "update", .no_checkin, "");
// #57 autosync pull, BEFORE resolution: the freshly pulled DAG may be the
// very thing the named check-in resolves against.
if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "update");
// SF1 (BLOCKER-FIX-PLAN.md §3): `update` used to accept ONLY a canonical
// 40/64-hex ID, so `zit update trunk` — the single most obvious thing to
// type — failed as an "invalid check-in ID". Its argument is a check-in
// name like every other verb's, so it goes through the same §8.3 procedure.
const target_id = switch (updateResolveTarget(gpa, io, arena, requested, streams)) {
.ok => |h| h,
.fail => |exit| return exit,
};
const root = (cli.discoverRoot(arena, io) catch {
streams.err.writeAll("zit update: out of memory\n") catch {};
return .failure;
}) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
var file = root_dir.openFile(io, repo_filename, .{}) catch {
streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {};
return .failure;
};
defer file.close(io);
const log_st = file.stat(io) catch {
streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {};
return .failure;
};
var fs = storage.FileStorage.init(io, file, log_st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |err|
return updateLogFail(streams, err);
defer log.deinit(gpa);
// Sparse-cone RFC section 4.1: --cone replaces the stored cone, --dense
// drops it, absent inherits. Cone.init validates members (worktree-path
// rules, coverage minimality); the parser only collected tokens.
var cone_members = parsed.cone_members;
var cone_len = parsed.cone_len;
if (parsed.cone_file) |cf| {
cone_len = cli.appendConeFile(arena, io, cf, &cone_members, cone_len) orelse {
streams.err.print("zit update: cannot read --cone-file '{s}' (or it overflows the {d}-member cap)\n", .{ cf, zit.cone.max_members }) catch {};
return .failure;
};
}
var new_cone: ?zit.cone.Cone = null;
defer if (new_cone) |*c| c.deinit(gpa);
const sparse: update.SparseOpt = if (parsed.dense)
.{ .set = null }
else if (cone_len != 0) blk: {
new_cone = zit.cone.Cone.init(gpa, cone_members[0..cone_len]) catch |e|
return coneInitFail(streams, "update", e);
break :blk .{ .set = &new_cone.? };
} else .inherit;
// The mutation entry: `target_id` is a full §3.6 artifact id — either the
// canonical argument or the resolved-and-duped hash (both validated above).
std.debug.assert(artifact.validateName(target_id));
update.applyCheckin(gpa, io, root_dir, &log, target_id, .inherit, sparse) catch |err| {
// Sparse-cone RFC section 6.4, policy form, update's half: content
// missing on a SPARSE store while the operator widens (or densifies)
// is elision meeting new coverage intent -- write the requested cone
// as store policy, pull through the saved remote, and retry once.
// On a dense store the same error stays the corruption it always was.
if (err == error.FileContentMissing) {
var probe = zit.repocache.readStoreCone(gpa, io, root_dir);
const store_is_sparse = probe != null;
if (probe) |*c| c.deinit(gpa);
if (store_is_sparse and updateDemandFetch(gpa, io, arena, root_dir, streams, &sparse)) {
// The log handle predates the fetch; the retry reopens
// everything through a fresh verb pass with fetching spent.
var file2 = root_dir.openFile(io, repo_filename, .{}) catch {
streams.err.writeAll("zit update: cannot reopen the repository after fetching\n") catch {};
return .failure;
};
defer file2.close(io);
const st2 = file2.stat(io) catch {
streams.err.writeAll("zit update: cannot reopen the repository after fetching\n") catch {};
return .failure;
};
var fs2 = storage.FileStorage.init(io, file2, st2.size);
var log2 = logstore.LogStore.openReadOnly(gpa, fs2.storageFile()) catch |e2|
return updateLogFail(streams, e2);
defer log2.deinit(gpa);
update.applyCheckin(gpa, io, root_dir, &log2, target_id, .inherit, sparse) catch |err2|
return updateApplyFail(streams, target_id, err2);
return .ok;
}
}
return updateApplyFail(streams, target_id, err);
};
return .ok;
}
/// The section 6.4 policy-form fetch for `update`: persist the REQUESTED
/// coverage as the store cone (a widened cone, or dense for --dense /
/// inherit-dense requests), then pull -- suppression and receive filtering
/// read the new policy. True on a completed pull; false (with the remedy
/// diagnostic) when no remote is saved.
/// The saved remote's URL, or null when none is stored. Mirrors the sync
/// verbs' resolver without importing their error mapping.
fn updateResolveRemote(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, buf: *[logstore.max_config_str]u8) ?[]const u8 {
var file = dir.openFile(io, repo_filename, .{}) catch return null;
defer file.close(io);
const st = file.stat(io) catch return null;
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return null;
defer log.deinit(gpa);
var oc = (log.readConfig(gpa) catch return null) orelse return null;
defer oc.deinit(gpa);
const saved = oc.config.remote_url;
if (saved.len == 0 or saved.len > buf.len) return null;
@memcpy(buf[0..saved.len], saved);
return buf[0..saved.len];
}
fn updateDemandFetch(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
streams: cli.Streams,
sparse: *const update.SparseOpt,
) bool {
const requested: ?*const zit.cone.Cone = switch (sparse.*) {
.set => |c| c,
.inherit => blk: {
// Inheriting on a sparse store and still missing content means
// the WORKTREE cone exceeds the STORE cone (or the store lost
// members); densify-to-worktree is not derivable here, so the
// honest policy write is the worktree's own stored cone -- which
// `applyCheckin` resolves itself. Densify fully instead: absent
// a --cone, the operator asked for whatever the checkout wants,
// and the store must at least hold that.
break :blk null;
},
};
var url_buf: [logstore.max_config_str]u8 = undefined;
_ = updateResolveRemote(gpa, io, root_dir, &url_buf) orelse {
streams.err.writeAll(
"zit update: content is elided by the store's sparse cone and no remote is saved\n" ++
"zit update: set one with 'zit remote URL', or re-clone with a wider --cone\n",
) catch {};
return false;
};
streams.err.writeAll("zit update: content is elided by the store's sparse cone; fetching from the saved remote\n") catch {};
zit.repocache.writeStoreCone(gpa, io, root_dir, requested);
const pull_exit = cmd_sync.runPull(gpa, io, arena, &.{}, streams);
return pull_exit == .ok;
}
/// `zit cone [add PATH... | remove PATH...]` — show or edit the checkout's
/// sparse cone (sparse-cone RFC section 3). Pure sugar: the edit composes
/// the stored member set with the delta and drives `runUpdate` with
/// `--cone=` flags at the current check-in, so demand fetch (section 6.4),
/// dematerialization, and the store-policy-follows rule all come free and
/// stay in one code path.
pub fn runCone(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
if (verb_args.len >= 1 and (std.mem.eql(u8, verb_args[0], "-h") or std.mem.eql(u8, verb_args[0], "--help"))) {
streams.out.writeAll(main.cone_help) catch {};
return .ok;
}
const root = (cli.discoverRoot(arena, io) catch {
streams.err.writeAll("zit cone: out of memory\n") catch {};
return .failure;
}) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
// The worktree cone: the checkout container's section, absent = dense.
var stored = update.readStoredCone(gpa, io, root_dir) catch {
streams.err.writeAll("zit cone: cannot read the checkout state\n") catch {};
return .failure;
};
defer if (stored) |*c| c.deinit(gpa);
if (verb_args.len == 0) {
if (stored) |*c| {
for (c.members) |m| streams.out.print("{s}\n", .{m}) catch {};
} else {
streams.out.writeAll("dense\n") catch {};
}
return .ok;
}
const action = verb_args[0];
const paths = verb_args[1..];
const adding = std.mem.eql(u8, action, "add");
const removing = std.mem.eql(u8, action, "remove");
if (!adding and !removing) return cli.usageError(streams, "cone", .bad_subcommand, action);
if (paths.len == 0) return cli.usageError(streams, "cone", .no_paths, "");
if (removing and stored == null) {
streams.err.writeAll("zit cone: this checkout is dense; there is no cone to remove from\n") catch {};
return .failure;
}
// Compose the new member set: stored ∪ paths, or stored ∖ paths. Bounded
// by the cone cap; Cone.init re-validates and re-normalizes the result.
var members: std.ArrayListUnmanaged([]const u8) = .empty;
defer members.deinit(gpa);
if (stored) |*c| {
for (c.members) |m| {
if (removing) {
var drop = false;
for (paths) |p2| {
if (std.mem.eql(u8, m, p2)) drop = true;
}
if (drop) continue;
}
members.append(gpa, m) catch return coneOom(streams);
}
}
if (adding) {
for (paths) |p2| members.append(gpa, p2) catch return coneOom(streams);
}
if (removing and members.items.len == 0) {
streams.err.writeAll("zit cone: removing every member would leave an empty cone; use 'zit update --dense' to densify\n") catch {};
return .failure;
}
if (members.items.len > zit.cone.max_members) {
streams.err.print("zit cone: the cone holds at most {d} members\n", .{zit.cone.max_members}) catch {};
return .failure;
}
// Resolve the current check-in and drive the one materialization path.
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const current = (checkout.read(io, root_dir, &ck_buf) catch {
streams.err.writeAll("zit cone: cannot read the checkout state\n") catch {};
return .failure;
}) orelse {
streams.err.writeAll("zit cone: no check-in is open\n") catch {};
return .failure;
};
if (current.version.len == 0) {
streams.err.writeAll("zit cone: no check-in is open\n") catch {};
return .failure;
}
var update_args: std.ArrayListUnmanaged([]const u8) = .empty;
defer update_args.deinit(gpa);
for (members.items) |m| {
const flag = std.fmt.allocPrint(arena, "--cone={s}", .{m}) catch return coneOom(streams);
update_args.append(gpa, flag) catch return coneOom(streams);
}
const version_owned = arena.dupe(u8, current.version) catch return coneOom(streams);
update_args.append(gpa, version_owned) catch return coneOom(streams);
return runUpdate(gpa, io, arena, update_args.items, streams);
}
fn coneOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit cone: out of memory\n") catch {};
return .failure;
}
/// Map a Cone.init refusal. The message states the rule, not a guess at the
/// operator's intent, and never says "missing" or "corrupt" (sparse-cone RFC
/// section 4.1 diagnostic discipline).
fn coneInitFail(s: cli.Streams, verb: []const u8, err: zit.cone.Error) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
error.InvalidMember => s.err.print(
"zit {s}: invalid --cone member: members are repository-relative paths using '/', with no '.', '..', or empty components\n",
.{verb},
) catch {},
error.ReservedMember => s.err.print("zit {s}: a --cone member names a reserved control path\n", .{verb}) catch {},
error.Bounds => s.err.print("zit {s}: --cone needs between 1 and 255 members\n", .{verb}) catch {},
}
return .failure;
}
/// Resolve `update`'s check-in argument to a canonical artifact ID (SF1).
///
/// A canonical 40/64-hex ID short-circuits: it needs no repository state, so
/// update's cold path (open the log read-only, hand the ID to the update
/// engine) is byte-for-byte what it was. Anything else — `trunk`, `tip`, a hash
/// prefix, a date form — opens the repository read-only and runs the shared
/// §8.3 procedure, then hands back the arena-owned canonical ID. The
/// repository view is closed again before the update transaction opens the log,
/// so the two never hold it at once.
fn updateResolveTarget(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
requested: []const u8,
streams: cli.Streams,
) union(enum) { ok: []const u8, fail: cli.Exit } {
if (artifact.validateName(requested)) return .{ .ok = requested };
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
// `.full` derive, not `.fast_read`: plain-name resolution reads the tag
// tables, which the read-image does not materialize.
const rc = openRepo(gpa, io, arena, streams, "update", null, false, false, &repo, &root_dir);
if (rc != .ok) return .{ .fail = rc };
defer root_dir.close(io);
defer repo.deinit(gpa);
const rid = repo.resolve(requested, .checkin) catch |e| switch (e) {
error.NotFound => {
streams.err.print("zit update: check-in '{s}' not found\n", .{requested}) catch {};
return .{ .fail = .failure };
},
error.Ambiguous => {
streams.err.print("zit update: name '{s}' is ambiguous\n", .{requested}) catch {};
return .{ .fail = .failure };
},
error.OutOfMemory => return .{ .fail = stateOom(streams, "update") },
};
// The hash borrows `repo`'s view, which dies with the `defer` above.
const owned = arena.dupe(u8, repo.hashOf(rid)) catch
return .{ .fail = stateOom(streams, "update") };
std.debug.assert(artifact.validateName(owned));
return .{ .ok = owned };
}
fn updateLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {},
else => s.err.print("zit update: cannot read repository: {s}\n", .{reasonOf(err)}) catch {},
}
return .failure;
}
// ==========================================================================
// `zit bisect SUBCOMMAND [NAME]` — binary-search the check-in DAG for the
// defect-introducing check-in (contracts/bisect.md B-bisect-1..20). The span
// math is ONE general-purpose repostate query (`Repository.bisectSpan`,
// ambiguities.md bisect-midpoint); moves ride the shipped update engine
// (`update.applyCheckin`, journal-armed); the session lives in `.zit-bisect`
// (the `undo.zig` state-file tier). This layer only orchestrates: parse, resolve,
// refuse, persist-then-move, announce.
// ==========================================================================
/// The 12-character display prefix of an artifact hash (timeline parity).
fn bisectShort(hash: []const u8) []const u8 {
return hash[0..@min(hash.len, timeline_hash_display)];
}
fn bisectDiag(s: cli.Streams, msg: []const u8) cli.Exit {
s.err.print("zit bisect: {s}\n", .{msg}) catch {};
return .failure;
}
fn bisectOom(s: cli.Streams) cli.Exit {
return bisectDiag(s, "out of memory");
}
fn bisectNoSession(s: cli.Streams) cli.Exit {
return bisectDiag(s, "no bisect in progress");
}
fn bisectInconsistent(s: cli.Streams) cli.Exit {
return bisectDiag(s, "bisect state names a check-in not in this repository; run 'zit bisect reset'");
}
/// Map a `.zit-bisect` read/write fault to its diagnostic. A corrupt file names
/// `reset` as the recovery (B-bisect-6), matching the `.zit-managed` parity.
fn bisectStateFail(s: cli.Streams, err: bisect.StateError) cli.Exit {
switch (err) {
error.OutOfMemory => return bisectOom(s),
error.Storage => return bisectDiag(s, "cannot read bisect state: input/output error"),
error.Corrupt => return bisectDiag(s, "cannot read bisect state: .zit-checkout is corrupt; run 'zit bisect reset' to recover"),
}
}
/// Map an update-engine move fault to its diagnostic. The check-in id and
/// baseline arms are unreachable for a check-in this view already resolved, so
/// they fold into the generic corrupt-repository message.
///
/// Exhaustive over `update.ApplyError` with NO `else`: bisect drives the same
/// engine as update, open and merge, so it reaches the same errors they do, and
/// a blanket `else` here silently answered `StateRecordInvalid` — reachable on
/// an intact clone — with a corruption claim. A new variant must fail this
/// compile and force a decision, as it does at the other three mappers.
fn bisectMoveFail(s: cli.Streams, err: update.ApplyError) cli.Exit {
switch (err) {
// applyCheckin never emits this: only applyMerge's cross-cone guard
// does, and bisect moves are checkin materializations. The unreachable
// IS the assertion; a future emitter trips it in tests immediately.
error.SparseMergeUnsupported => unreachable,
error.OutOfMemory => return bisectOom(s),
error.FileContentMissing => {
var b: [512]u8 = undefined;
return bisectDiag(s, update.stageFaultReason(&b));
},
error.WorktreeNotClean => return bisectDiag(s, "working tree is not clean"),
error.UnmanagedCollision => return bisectDiag(s, "an untracked file or directory is in the way"),
error.Storage => return bisectDiag(s, "cannot update working tree: input/output error"),
error.TransactionUnavailable => return bisectDiag(s, "cannot create a transaction directory"),
error.RollbackFailed => return bisectDiag(s, "move failed and could not be rolled back; the previous working-tree state is retained for recovery"),
error.CleanupFailed => return bisectDiag(s, "move committed, but the transaction directory could not be removed"),
error.StateRecordInvalid => return bisectDiag(s, "cannot read repository: " ++ update.state_record_invalid_reason),
error.CurrentStateInvalid => return bisectDiag(s, "current working-tree state is invalid"),
error.InvalidPath => return bisectDiag(s, "the check-in names an invalid file path"),
error.ReservedPath => return bisectDiag(s, "the check-in names a reserved file path"),
error.SymlinkUnsupported => return bisectDiag(s, "the check-in contains an unsupported symlink entry"),
error.Corrupt,
error.InvalidCheckinId,
error.CheckinNotFound,
error.CheckinContentMissing,
error.CheckinNameMismatch,
error.AggregateChecksumMismatch,
error.TargetNotManifest,
error.BaselineNotFound,
error.BaselineContentMissing,
error.BaselineNameMismatch,
error.BaselineNotManifest,
error.BaselineIsDelta,
=> return bisectDiag(s, "cannot read repository: repository is corrupt"),
}
}
/// Resolve every stored mark hash to its rid. `Missing` == the session names a
/// hash this view never interned (an inconsistent state).
fn bisectRids(gpa: std.mem.Allocator, repo: *Repository, hashes: []const []const u8) error{ OutOfMemory, Missing }![]Repository.Rid {
const out = gpa.alloc(Repository.Rid, hashes.len) catch return error.OutOfMemory;
errdefer gpa.free(out);
for (hashes, out) |h, *r| r.* = repo.lookup(h) orelse return error.Missing;
return out;
}
/// Drop every occurrence of `hash` from `list` (content compare), so re-marking a
/// check-in moves it between the good/bad/skip sets rather than duplicating it.
fn bisectRemove(list: *std.ArrayList([]const u8), hash: []const u8) void {
var i: usize = 0;
while (i < list.items.len) {
if (std.mem.eql(u8, list.items[i], hash)) {
_ = list.orderedRemove(i);
} else i += 1;
}
}
// ===========================================================================
// `zit annotate PATH` (contracts/annotate.md B-annotate-1..16). Per-line
// provenance of one tracked file at the current check-out: which check-in
// introduced each line, and when. The version chain is the file's FULL ancestry
// materialized through `Repository.fileAt` (the §8 effective set) + `blob`, with
// adjacency from the repostate §3.3 parent query; adjacent versions are diffed
// by the ONE house line-diff core (`merge3.matchLines`); the pure `annotate`
// engine pushes origins backward per ambiguities.md `annotate-attribution`. This
// layer is only I/O + rendering — no second history walker, resolver, or diff.
// ===========================================================================
pub fn runBisect(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
const parsed = switch (cli.parseBisectArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "bisect", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "bisect", .extra_argument, tok),
.bad_subcommand => |tok| return cli.usageError(streams, "bisect", .bad_subcommand, tok),
.missing_name => return cli.usageError(streams, "bisect", .no_name, ""),
};
if (parsed.help) {
streams.out.writeAll(bisect_help) catch {};
return .ok;
}
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
const rc = openRepo(gpa, io, arena, streams, "bisect", null, false, false, &repo, &root_dir);
if (rc != .ok) return rc;
defer root_dir.close(io);
defer repo.deinit(gpa);
return switch (parsed.sub) {
.bad => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .bad),
.good => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .good),
.next => bisectNext(gpa, io, &repo, root_dir, streams),
.status => bisectStatus(gpa, io, &repo, root_dir, streams),
.reset => bisectReset(gpa, io, &repo, root_dir, streams),
};
}
/// `zit bisect bad|good NAME`: record the mark, then — once both a bad and a good
/// endpoint exist — narrow the span and move the checkout to the next probe
/// (B-bisect-1/3). A lone endpoint is recorded and confirmed without moving.
fn bisectMark(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
streams: cli.Streams,
name: []const u8,
kind: cli.BisectSub,
) cli.Exit {
const rid = repo.resolve(name, .checkin) catch |e| switch (e) {
error.NotFound => {
streams.err.print("zit bisect: check-in '{s}' not found\n", .{name}) catch {};
return .failure;
},
error.Ambiguous => {
streams.err.print("zit bisect: name '{s}' is ambiguous\n", .{name}) catch {};
return .failure;
},
error.OutOfMemory => return stateOom(streams, "bisect"),
};
const hash = repo.hashOf(rid);
var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
defer if (sess_opt) |*s| s.deinit();
var work = std.heap.ArenaAllocator.init(gpa);
defer work.deinit();
const a = work.allocator();
var bad_list: std.ArrayList([]const u8) = .empty;
var good_list: std.ArrayList([]const u8) = .empty;
var skip_list: std.ArrayList([]const u8) = .empty;
var start: []const u8 = "";
if (sess_opt) |s| {
start = s.start;
for (s.bad) |x| bad_list.append(a, x) catch return bisectOom(streams);
for (s.good) |x| good_list.append(a, x) catch return bisectOom(streams);
for (s.skipped) |x| skip_list.append(a, x) catch return bisectOom(streams);
} else {
// Fresh session: the current checkout is the restore point for `reset`.
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
if (checkout.read(io, root_dir, &ck_buf) catch null) |ck| {
start = a.dupe(u8, ck.version) catch return bisectOom(streams);
}
}
const dh = a.dupe(u8, hash) catch return bisectOom(streams);
bisectRemove(&bad_list, dh);
bisectRemove(&good_list, dh);
bisectRemove(&skip_list, dh);
switch (kind) {
.bad => bad_list.append(a, dh) catch return bisectOom(streams),
.good => good_list.append(a, dh) catch return bisectOom(streams),
else => unreachable,
}
const has_both = bad_list.items.len != 0 and good_list.items.len != 0;
if (!has_both) {
// Record the lone endpoint; no move (B-bisect-1 first half).
bisect.write(gpa, io, root_dir, start, bad_list.items, good_list.items, skip_list.items) catch |e|
return bisectStateFail(streams, e);
streams.out.print("bisect: marked {s} {s}\n", .{ bisectShort(hash), @tagName(kind) }) catch {};
const other = if (kind == .bad) "good" else "bad";
streams.out.print("bisect: mark a {s} check-in to begin bisecting\n", .{other}) catch {};
return .ok;
}
return bisectAdvance(gpa, io, repo, root_dir, streams, start, bad_list.items, good_list.items, skip_list.items, true);
}
/// Derive the span, refuse over a dirty tree / inverted / unrelated marks
/// (mutating nothing), then — only on success — persist the state and move the
/// checkout to the probe (or the culprit when the span is exhausted). Shared by
/// marking (persist == true) and `next` (persist == true, idempotent rewrite).
fn bisectAdvance(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
streams: cli.Streams,
start: []const u8,
bad_hashes: []const []const u8,
good_hashes: []const []const u8,
skip_hashes: []const []const u8,
persist: bool,
) cli.Exit {
// Precondition: both endpoints exist — a caller only advances once a bad AND a
// good mark are present (`bisectMark`'s `has_both`, `bisectNext`'s guard), and
// `bisectSpan` needs both to bound the span.
std.debug.assert(bad_hashes.len != 0 and good_hashes.len != 0);
// Dirty-tree refusal first — the shipped update discipline (B-bisect-9),
// mutating nothing.
const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "bisect", e);
update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
error.WorktreeNotClean => return bisectDiag(streams, "working tree is not clean"),
else => return bisectMoveFail(streams, e),
};
const bad_rids = bisectRids(gpa, repo, bad_hashes) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(bad_rids);
const good_rids = bisectRids(gpa, repo, good_hashes) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(good_rids);
const skip_rids = bisectRids(gpa, repo, skip_hashes) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(skip_rids);
const out = repo.bisectSpan(gpa, bad_rids, good_rids, skip_rids) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.InvertedMarks => return bisectDiag(streams, "the bad check-in is an ancestor of a good check-in; the marks are inverted"),
error.UnrelatedHistories => return bisectDiag(streams, "the good and bad marks share no common history"),
};
// Persist the validated state durably BEFORE the move (B-bisect-6): a crash
// leaves the marks recorded and the probe re-derivable.
if (persist) {
bisect.write(gpa, io, root_dir, start, bad_hashes, good_hashes, skip_hashes) catch |e|
return bisectStateFail(streams, e);
}
if (out.probe) |probe| {
const target = repo.hashOf(probe);
update.applyCheckin(gpa, io, root_dir, log, target, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);
streams.out.print(
"bisect: {d} untested candidate(s) remain; testing {s}\n",
.{ out.untested, bisectShort(target) },
) catch {};
return .ok;
}
return bisectAnnounceCulprit(gpa, io, repo, root_dir, log, streams, out.culprit);
}
/// Termination (B-bisect-4): move the checkout to the culprit and announce it
/// with its comment line. The session stays recorded until `reset`.
fn bisectAnnounceCulprit(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
log: *logstore.LogStore,
streams: cli.Streams,
culprit: Repository.Rid,
) cli.Exit {
const target = repo.hashOf(culprit);
update.applyCheckin(gpa, io, root_dir, log, target, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);
// The comment borrows the manifest bytes, so print before it is freed; the
// override value borrows tagxref bytes valid for the repo's lifetime.
var maybe = repo.manifest(gpa, culprit) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
else => return bisectDiag(streams, "cannot read repository: repository is corrupt"),
};
defer if (maybe) |*m| m.deinit(gpa);
const manifest_comment: []const u8 = if (maybe) |m|
(if (m.manifest.c) |c| firstCommentLine(c.comment) else "")
else
"";
const comment = repo.override(culprit, "comment", manifest_comment);
streams.out.print("bisect: {s} is the first bad check-in\n", .{bisectShort(target)}) catch {};
streams.out.print(" {s}\n", .{comment}) catch {};
return .ok;
}
/// `zit bisect next`: re-derive and re-announce the current probe, moving the
/// checkout to it. Refuses with no session or with only one endpoint (B-bisect-13).
fn bisectNext(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
defer if (sess_opt) |*s| s.deinit();
const s = sess_opt orelse return bisectNoSession(streams);
if (s.bad.len == 0 or s.good.len == 0) {
const missing = if (s.bad.len == 0) "bad" else "good";
streams.err.print("zit bisect: mark a {s} check-in before running 'zit bisect next'\n", .{missing}) catch {};
return .failure;
}
return bisectAdvance(gpa, io, repo, root_dir, streams, s.start, s.bad, s.good, s.skipped, false);
}
/// `zit bisect status`: report the marks, the current probe, and the remaining
/// count without moving (B-bisect-7). Never refuses on a dirty tree; with no
/// session it is exit 1, nothing mutated.
fn bisectStatus(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e);
defer if (sess_opt) |*s| s.deinit();
const s = sess_opt orelse return bisectNoSession(streams);
for (s.bad) |h| streams.out.print("bisect: bad {s}\n", .{bisectShort(h)}) catch {};
for (s.good) |h| streams.out.print("bisect: good {s}\n", .{bisectShort(h)}) catch {};
for (s.skipped) |h| streams.out.print("bisect: skip {s}\n", .{bisectShort(h)}) catch {};
if (s.bad.len == 0 or s.good.len == 0) {
const other = if (s.bad.len == 0) "bad" else "good";
streams.out.print("bisect: mark a {s} check-in to begin bisecting\n", .{other}) catch {};
return .ok;
}
const bad_rids = bisectRids(gpa, repo, s.bad) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(bad_rids);
const good_rids = bisectRids(gpa, repo, s.good) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(good_rids);
const skip_rids = bisectRids(gpa, repo, s.skipped) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.Missing => return bisectInconsistent(streams),
};
defer gpa.free(skip_rids);
const out = repo.bisectSpan(gpa, bad_rids, good_rids, skip_rids) catch |e| switch (e) {
error.OutOfMemory => return bisectOom(streams),
error.InvertedMarks => return bisectDiag(streams, "the bad check-in is an ancestor of a good check-in; the marks are inverted"),
error.UnrelatedHistories => return bisectDiag(streams, "the good and bad marks share no common history"),
};
if (out.probe) |probe| {
streams.out.print(
"bisect: testing {s} ({d} untested candidate(s) remain)\n",
.{ bisectShort(repo.hashOf(probe)), out.untested },
) catch {};
} else {
streams.out.print("bisect: {s} is the first bad check-in\n", .{bisectShort(repo.hashOf(out.culprit))}) catch {};
}
return .ok;
}
/// `zit bisect reset`: restore the starting check-in, remove `.zit-bisect`, exit 0
/// (B-bisect-8). No session is exit 1; a corrupt file is cleared (reset is the
/// advertised recovery, B-bisect-6). The restore move rides the update engine and
/// is refused over a dirty tree (B-bisect-9).
fn bisectReset(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
var sess_opt = bisect.read(gpa, io, root_dir) catch |e| switch (e) {
error.Corrupt => {
// reset IS the recovery: clear the damaged file rather than deadlock.
bisect.clear(io, root_dir);
streams.out.writeAll("bisect: cleared corrupt bisect state (working tree not restored)\n") catch {};
return .ok;
},
else => return bisectStateFail(streams, e),
};
defer if (sess_opt) |*s| s.deinit();
const s = sess_opt orelse return bisectNoSession(streams);
const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "bisect", e);
update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
error.WorktreeNotClean => return bisectDiag(streams, "working tree is not clean"),
else => return bisectMoveFail(streams, e),
};
if (s.start.len != 0) {
update.applyCheckin(gpa, io, root_dir, log, s.start, .inherit, .inherit) catch |e| return bisectMoveFail(streams, e);
bisect.clear(io, root_dir);
streams.out.print("bisect: reset; working tree restored to {s}\n", .{bisectShort(s.start)}) catch {};
} else {
bisect.clear(io, root_dir);
streams.out.writeAll("bisect: reset\n") catch {};
}
return .ok;
}
// ==========================================================================
// `zit merge NAME` — compute a line-based three-way merge of NAME (the "other"
// side) into the working tree and record it as a pending merge parent. Thin
// orchestration: resolve + refusal gate + pure `merge3` classification, then
// `update.applyMerge` materializes and records the parent. It does NOT mint a
// check-in — `zit commit` does that from the recorded state (contracts/merge.md
// B-merge-1..29). Conflicts are written in-tree with markers, listed on stderr,
// exit 1 — but the merge parent is still recorded (`zit undo` is the escape).
// ==========================================================================
pub fn runMerge(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
// 1. Parse the one-positional NAME plus `-h`/`--help` and the two mutually-
// exclusive baseline flags `--cherrypick`/`--backout` (B-merge-25/26/40).
// Help wins; an unknown option, a second positional, or both flags is a
// usage error (exit 2). A missing NAME is a usage error — NAME is
// mandatory (unlike `update`, which defaults to tip). The flagless grammar
// stays byte-identical to the shipped surface (B-merge-31).
const parsed = switch (cli.parseMergeArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "merge", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "merge", .extra_argument, tok),
.conflicting_flags => {
streams.err.writeAll(
"zit merge: --cherrypick and --backout cannot be combined\n",
) catch {};
streams.err.writeAll("try 'zit merge --help'\n") catch {};
return .usage;
},
};
if (parsed.help) {
streams.out.writeAll(merge_help) catch {};
return .ok;
}
const name = parsed.checkin orelse return cli.usageError(streams, "merge", .no_name, "");
// #57 autosync pull, before resolution — same reasoning as `update`'s.
if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "merge");
// 2. Open the repository through the read-only facade (B-merge-27): a missing
// repo / read fault is exit 1, distinct from the exit-2 usage class, and
// the repository check precedes any resolution or working-tree read.
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
const rc = openRepo(gpa, io, arena, streams, "merge", null, false, false, &repo, &root_dir);
if (rc != .ok) return rc;
defer root_dir.close(io);
defer repo.deinit(gpa);
// 3. Resolve NAME (repo-state §8.3) → the "other" side. Unresolvable or
// ambiguous is a runtime failure (exit 1, B-merge-18), nothing mutated.
const other = repo.resolve(name, .checkin) catch |e| {
switch (e) {
error.NotFound => streams.err.print("zit merge: check-in '{s}' not found\n", .{name}) catch {},
error.Ambiguous => streams.err.print("zit merge: name '{s}' is ambiguous\n", .{name}) catch {},
error.OutOfMemory => return stateOom(streams, "merge"),
}
return .failure;
};
// 4. The current checkout is the merge's "current" side. An unborn checkout
// has nothing to merge into.
const current = repo.currentCheckoutRid() orelse {
streams.err.writeAll("zit merge: no current check-in to merge into\n") catch {};
return .failure;
};
// 5. Refusal gate — nothing mutated on any refusal (all checked BEFORE
// `applyMerge`). Read the checkout for a pending-merge marker FIRST: an
// unresolved prior merge leaves conflict markers in the tree, so a bare
// dirty-tree check would misreport it — the pending check must precede it
// (B-merge-15).
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (checkout.read(io, root_dir, &ck_buf) catch |e|
return checkoutFail(streams, "merge", e)) orelse {
streams.err.writeAll("zit merge: no current check-in to merge into\n") catch {};
return .failure;
};
if (ck.merge_parent.len != 0 or ck.special_op.len != 0) {
streams.err.writeAll(
"zit merge: a merge is already pending; resolve and commit it, or run 'zit undo'\n",
) catch {};
return .failure;
}
// 6. Dirty-tree refusal (B-merge-14): the merge computes over the checkout's
// effective content (never a disk re-read), so a divergent working tree is
// rejected up front — the same `requireClean` gate `update` runs. Checked
// before the ancestry gates so a dirty tree reads as "not clean" rather
// than being masked by an up-to-date/unrelated verdict (plan §3 order).
const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "merge", e);
update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) {
error.WorktreeNotClean => {
streams.err.writeAll("zit merge: working tree is not clean\n") catch {};
return .failure;
},
else => return mergeApplyFail(streams, e),
};
// 6b. Special merges (merge-flags.md) choose an ALTERNATE baseline inside
// this same machinery — cherrypick applies REV's own change, backout
// reverses it — so they skip the plain-merge ancestry gates (up-to-date /
// unrelated-histories / merge-base) entirely and route straight through
// `classifyFile`/`applyMerge` with base/other picked per the flag. A root
// REV (no primary parent) uses the EMPTY file set for its parent side, so
// no refusal is needed (error defined out of existence).
if (parsed.special) |sp| {
const rev = other;
const pp = repo.state().primaryParentOf(rev);
const sel = specialBaseline(sp, rev, pp);
const rev_hash = repo.hashOf(rev);
const sign: u8 = if (sp == .cherrypick) '+' else '-';
// The pending op recorded in checkout state: the q-sign byte glued onto
// REV's hash (checkout plan E `special_op`). Arena-owned; outlives applyMerge.
const special_op = std.fmt.allocPrint(arena, "{c}{s}", .{ sign, rev_hash }) catch
return mergeOom(streams);
return computeAndApplyMerge(gpa, io, arena, &repo, root_dir, sel.base, current, sel.other, ck, .{
.merge_parent = "",
.special_op = special_op,
.report_verb = if (sp == .cherrypick) "cherry-picked" else "backed out",
.report_hash = rev_hash,
.refuse_empty = true,
}, streams);
}
// 7. Up to date (B-merge-16): NAME is an ancestor of the checkout, itself
// included → the checkout already contains it, refuse. This is the pure
// §3.3 full-ancestry membership test (`isAncestorOf`), mtime-INDEPENDENT —
// NOT `base == other`, which the merge-base mtime tiebreak defeats when
// `other` ties another ancestor on mtime and loses the least-hash
// selection: the base is then a different node, the equality misses, and
// an ancestor would wrongly be merged as a parent. Self-membership
// subsumes the self-merge case (other == current).
if (repo.isAncestorOf(gpa, other, current) catch return mergeOom(streams)) {
streams.err.writeAll("zit merge: already up to date; nothing to merge\n") catch {};
return .failure;
}
// 8. Merge base over FULL ancestry (repo-state `mergeBaseOf`; ambiguities.md
// merge-base). No common ancestor → unrelated histories, refuse
// (B-merge-17). The up-to-date case (NAME an ancestor of the checkout) was
// already refused in step 7 by the mtime-independent ancestry test, so
// `base` here is strictly the three-way merge base.
const base = (repo.mergeBaseOf(gpa, current, other) catch return mergeOom(streams)) orelse {
streams.err.print(
"zit merge: '{s}' shares no history with the current check-in\n",
.{name},
) catch {};
return .failure;
};
// 9. Compute the outcome set purely and materialize it, recording other's
// hash as the pending merge parent. Reports last.
return computeAndApplyMerge(gpa, io, arena, &repo, root_dir, base, current, other, ck, .{
.merge_parent = repo.hashOf(other),
.special_op = "",
.report_verb = "merged",
.report_hash = repo.hashOf(other),
.refuse_empty = false,
}, streams);
}
/// The alternate baseline a special merge computes over (merge-flags.md
/// semantics): with REV's primary parent PP —
/// `--cherrypick`: base = PP, other = REV — applies REV's own change.
/// `--backout`: base = REV, other = PP — applies its reverse.
/// A root REV has `pp == null`, so the parent side is null → the EMPTY file set
/// (error defined out of existence). Pure: the whole baseline decision, unit-
/// testable on rids alone.
const SpecialBaseline = struct { base: ?repostate.Rid, other: ?repostate.Rid };
fn specialBaseline(sp: cli.SpecialMerge, rev: repostate.Rid, pp: ?repostate.Rid) SpecialBaseline {
return switch (sp) {
.cherrypick => .{ .base = pp, .other = rev },
.backout => .{ .base = rev, .other = pp },
};
}
/// What `computeAndApplyMerge` records and reports: the pending checkout fields
/// (exactly one of `merge_parent`/`special_op` is non-empty), the success-line
/// verb/hash, and whether a no-op application refuses (special merges) versus
/// falls through as a zero-change merge (plain merges, unchanged behavior).
const MergeApply = struct {
merge_parent: []const u8,
special_op: []const u8,
report_verb: []const u8,
report_hash: []const u8,
refuse_empty: bool,
};
test "specialBaseline: cherrypick is PP→REV, backout is REV→PP (merge-flags.md)" {
const rev: repostate.Rid = 7;
const pp: repostate.Rid = 3;
// cherrypick applies REV's own change: base = its primary parent, other = REV.
const cp = specialBaseline(.cherrypick, rev, pp);
try std.testing.expectEqual(@as(?repostate.Rid, pp), cp.base);
try std.testing.expectEqual(@as(?repostate.Rid, rev), cp.other);
// backout reverses it: base = REV, other = its primary parent.
const bo = specialBaseline(.backout, rev, pp);
try std.testing.expectEqual(@as(?repostate.Rid, rev), bo.base);
try std.testing.expectEqual(@as(?repostate.Rid, pp), bo.other);
// A root REV (no primary parent): the parent side is the EMPTY file set
// (null) — cherrypick's base and backout's other. No refusal needed.
const cp_root = specialBaseline(.cherrypick, rev, null);
try std.testing.expectEqual(@as(?repostate.Rid, null), cp_root.base);
try std.testing.expectEqual(@as(?repostate.Rid, rev), cp_root.other);
const bo_root = specialBaseline(.backout, rev, null);
try std.testing.expectEqual(@as(?repostate.Rid, rev), bo_root.base);
try std.testing.expectEqual(@as(?repostate.Rid, null), bo_root.other);
}
/// One path's F card on each of the three merge sides (null = absent there).
const MergeSlot = struct {
base: ?artifact.FCard = null,
current: ?artifact.FCard = null,
other: ?artifact.FCard = null,
};
/// The current-side attribute wins (B-merge-23); when the path is added only on
/// the other side, its attribute is the only one there.
/// Optional-hash equality for the record-level three-way: both-absent is
/// equal; one-sided absence is not; present compares byte-wise.
fn hashEql(a: ?[]const u8, b: ?[]const u8) bool {
if (a == null and b == null) return true;
if (a == null or b == null) return false;
return std.mem.eql(u8, a.?, b.?);
}
fn mergeSlotPerm(slot: MergeSlot) ?[]const u8 {
if (slot.current) |f| return f.perm;
if (slot.other) |f| return f.perm;
return null;
}
/// Step 8 of `runMerge`, split out for legibility: read the three §8.2 effective
/// file sets, classify every path with the pure `merge3` engine into a
/// `MergeEntry` list, hand it to `update.applyMerge` (which journals, records the
/// merge parent, and materializes), then report. Conflicts are written in-tree
/// and listed on stderr with exit 1; the merge parent is recorded either way.
fn computeAndApplyMerge(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
repo: *Repository,
root_dir: std.Io.Dir,
base: ?repostate.Rid,
current: repostate.Rid,
other: ?repostate.Rid,
ck: checkout.CheckoutState,
spec: MergeApply,
streams: cli.Streams,
) cli.Exit {
// The three effective file sets (§8.2 merge-walk). Kept alive until after
// `applyMerge` — every `MergeEntry.path`/`perm` borrows these manifests. A
// null side is the EMPTY file set (a special merge whose REV is a root, so
// its primary-parent side has no manifest).
var base_list: ?Repository.FileList = if (base) |b|
(repo.files(gpa, b) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams)
else
null;
defer if (base_list) |*l| l.deinit(gpa);
var cur_list = (repo.files(gpa, current) catch |e| return mergeFilesFail(streams, e)) orelse
return mergeCorrupt(streams);
defer cur_list.deinit(gpa);
var oth_list: ?Repository.FileList = if (other) |o|
(repo.files(gpa, o) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams)
else
null;
defer if (oth_list) |*l| l.deinit(gpa);
// The union of paths across the three sides; each slot names the side's card.
// Iteration order is unspecified — the conflict list is sorted before it is
// reported and `applyMerge` is order-independent, so nothing depends on it.
var slots = std.StringHashMap(MergeSlot).init(arena);
if (base_list) |*bl| {
var bit = bl.iterator();
while (bit.next()) |f| {
const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
if (!gop.found_existing) gop.value_ptr.* = .{};
gop.value_ptr.base = f;
}
}
var cit = cur_list.iterator();
while (cit.next()) |f| {
const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
if (!gop.found_existing) gop.value_ptr.* = .{};
gop.value_ptr.current = f;
}
if (oth_list) |*ol| {
var oit = ol.iterator();
while (oit.next()) |f| {
const gop = slots.getOrPut(f.filename) catch return mergeOom(streams);
if (!gop.found_existing) gop.value_ptr.* = .{};
gop.value_ptr.other = f;
}
}
// Classify every path (pure), building the outcome entries and the conflict
// list. Bytes/results are arena-owned and outlive `applyMerge`.
var entries: std.ArrayList(update.MergeEntry) = .empty;
var conflicts: std.ArrayList([]const u8) = .empty;
// Sparse-cone RFC section 4.4: out-of-cone paths resolve at HASH level
// (record-level application) -- no blob read, no materialization. The
// resolved outcomes accumulate into the merge_overlay the next commit
// composes; a both-changed path is a genuine content conflict that
// cannot resolve without bytes this checkout deliberately lacks.
var merge_cone = update.readStoredCone(gpa, io, root_dir) catch {
streams.err.writeAll("zit merge: cannot read the checkout state\n") catch {};
return .failure;
};
defer if (merge_cone) |*c| c.deinit(gpa);
var overlay: std.ArrayList(update.OverlayEntry) = .empty;
var cross_conflicts: std.ArrayList([]const u8) = .empty;
var it = slots.iterator();
while (it.next()) |kv| {
const path = kv.key_ptr.*;
const slot = kv.value_ptr.*;
if (merge_cone) |*c| {
if (!c.covers(path)) {
const base_h: ?[]const u8 = if (slot.base) |f| f.hash else null;
const cur_h: ?[]const u8 = if (slot.current) |f| f.hash else null;
const oth_h: ?[]const u8 = if (slot.other) |f| f.hash else null;
if (hashEql(oth_h, cur_h)) {
// Same on both sides (including both absent): nothing.
} else if (hashEql(cur_h, base_h)) {
// Theirs-only change: adopt theirs' card by hash.
overlay.append(arena, .{
.path = path,
.hash = oth_h,
.perm = if (slot.other) |f| f.perm else null,
}) catch return mergeOom(streams);
} else if (hashEql(oth_h, base_h)) {
// Ours-only (ancestral) change: ours stands via carry.
} else {
cross_conflicts.append(arena, path) catch return mergeOom(streams);
}
continue;
}
}
// The bytes of each side (null = path absent there). The §8.2 walk
// suppresses hash-less delete markers, so a present card always names a
// stored blob; a missing blob is a corruption. A read fault returns an
// already-rendered `cli.Exit` from this function verbatim.
const base_side: merge3.Side = if (slot.base) |f|
(repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
else
null;
const cur_side: merge3.Side = if (slot.current) |f|
(repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
else
null;
const oth_side: merge3.Side = if (slot.other) |f|
(repo.blob(arena, f.hash.?) catch |e| return repoOpenFail(streams, "merge", e)) orelse return mergeCorrupt(streams)
else
null;
const outcome = merge3.classifyFile(arena, base_side, cur_side, oth_side) catch return mergeOom(streams);
const perm = mergeSlotPerm(slot);
switch (outcome) {
.unchanged => {},
.delete => entries.append(arena, .{ .path = path, .action = .delete }) catch return mergeOom(streams),
.take_other => entries.append(arena, .{
.path = path,
.action = .{ .write = .{ .bytes = oth_side.?, .perm = perm } },
}) catch return mergeOom(streams),
.clean_merge => |r| entries.append(arena, .{
.path = path,
.action = .{ .write = .{ .bytes = r.bytes, .perm = perm } },
}) catch return mergeOom(streams),
.text_conflict => |r| {
entries.append(arena, .{
.path = path,
.action = .{ .write = .{ .bytes = r.bytes, .perm = perm } },
}) catch return mergeOom(streams);
conflicts.append(arena, path) catch return mergeOom(streams);
},
.whole_file_conflict => {
const bytes = merge3.writeEnvelope(arena, cur_side, base_side, oth_side) catch return mergeOom(streams);
entries.append(arena, .{
.path = path,
.action = .{ .write = .{ .bytes = bytes, .perm = perm } },
}) catch return mergeOom(streams);
conflicts.append(arena, path) catch return mergeOom(streams);
},
}
}
// A special merge whose change is already contained in the current checkout
// classifies to zero entries — refuse before mutating anything (B-merge-39),
// parallel to the plain merge's up-to-date refusal. A plain merge never sets
// `refuse_empty`, so its behavior (call `applyMerge` regardless) is unchanged.
if (spec.refuse_empty and entries.items.len == 0) {
streams.err.writeAll("zit merge: nothing to apply\n") catch {};
return .failure;
}
// Materialize (journal-armed) and record the pending operation: a plain
// merge's `merge_parent` (other's hash) or a special merge's `special_op`
// (sign + REV). `applyMerge` writes the checkout FIRST, so a conflict still
// records the pending state for `zit commit`/`zit undo` (B-merge-3/7/35).
// Exactly one pending operation is recorded: a plain merge's `merge_parent`
// (other's hash) XOR a special merge's `special_op` (sign + REV).
if (cross_conflicts.items.len != 0) {
streams.err.print(
"zit merge: {d} out-of-cone file(s) changed on both sides, starting with '{s}'\n" ++
"zit merge: resolving them needs content this checkout's sparse cone excludes;\n" ++
"zit merge: widen the cone ('zit cone add ...') and re-run the merge\n",
.{ cross_conflicts.items.len, cross_conflicts.items[0] },
) catch {};
return .failure;
}
const overlay_bytes: ?[]u8 = if (overlay.items.len != 0)
update.encodeOverlay(gpa, overlay.items) catch |e| switch (e) { error.OutOfMemory => return mergeOom(streams), error.Overflow => return mergeCorrupt(streams) }
else
null;
defer if (overlay_bytes) |b| gpa.free(b);
std.debug.assert((spec.merge_parent.len == 0) != (spec.special_op.len == 0));
update.applyMerge(gpa, io, root_dir, entries.items, overlay_bytes, .{
.version = ck.version,
.branch = ck.branch,
.merge_parent = spec.merge_parent,
.special_op = spec.special_op,
// A merge changes the check-in, never the checkout's manifest opt-in.
.manifest_flags = ck.manifest_flags,
}) catch |e| return mergeApplyFail(streams, e);
if (overlay.items.len != 0) {
streams.out.print("{d} out-of-cone change(s) applied to the record\n", .{overlay.items.len}) catch {};
}
// Report last. Conflicts → stderr only, exit 1; clean → the confirmation to
// stdout only, exit 0 (B-merge-28 stream separation).
if (conflicts.items.len != 0) {
std.mem.sort([]const u8, conflicts.items, {}, lessByHash);
for (conflicts.items) |p| streams.err.print("zit merge: conflict in {s}\n", .{p}) catch {};
streams.err.print(
"zit merge: {d} conflict(s); resolve and commit, or run 'zit undo'\n",
.{conflicts.items.len},
) catch {};
return .failure;
}
streams.out.print(
"{s} {s} into the working tree ({d} file(s) changed)\n",
.{ spec.report_verb, spec.report_hash, entries.items.len },
) catch {};
return .ok;
}
fn mergeOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit merge: out of memory\n") catch {};
return .failure;
}
fn mergeCorrupt(s: cli.Streams) cli.Exit {
s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {};
return .failure;
}
/// Maps a `Repository.files` fault to the shipped diagnostic phrasing. The three
/// §8 enumeration faults and a corrupt manifest all read as "repository is
/// corrupt" — a resolved name whose manifests do not enumerate is a data fault.
fn mergeFilesFail(s: cli.Streams, err: Repository.FilesError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit merge: out of memory\n") catch {},
error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
error.CannotRead => s.err.writeAll("zit merge: cannot read repository: input/output error\n") catch {},
error.Corrupt,
error.NotManifest,
error.BaselineUnresolvable,
error.BaselineIsDelta,
=> s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {},
}
return .failure;
}
/// Maps an `update.ApplyError` from the merge materialization (or the clean-tree
/// gate) to the shipped `zit merge:` phrasing, mirroring `updateApplyFail`. The
/// check-in/baseline id faults cannot arise here — `files` already validated the
/// resolved manifests — but `ApplyError` is one closed set, so they fold into
/// the corrupt-repository message rather than an invented one.
fn mergeApplyFail(s: cli.Streams, err: update.ApplyError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit merge: out of memory\n") catch {},
error.FileContentMissing => {
var b: [512]u8 = undefined;
s.err.print("zit merge: {s}\n", .{update.stageFaultReason(&b)}) catch {};
},
error.SparseMergeUnsupported => s.err.writeAll(
"zit merge: the merge changes files outside this checkout's sparse cone\n" ++
"record-level application is not supported yet; widen the cone with\n" ++
"'zit update --cone PATH' (or return to dense with --dense) and re-run the merge\n",
) catch {},
error.WorktreeNotClean => s.err.writeAll("zit merge: working tree is not clean\n") catch {},
error.CurrentStateInvalid => s.err.writeAll("zit merge: current working-tree state is invalid\n") catch {},
error.UnmanagedCollision => s.err.writeAll("zit merge: an untracked file or directory is in the way\n") catch {},
error.InvalidPath => s.err.writeAll("zit merge: the merge names an invalid file path\n") catch {},
error.ReservedPath => s.err.writeAll("zit merge: the merge names a reserved file path\n") catch {},
error.SymlinkUnsupported => s.err.writeAll("zit merge: the merge contains an unsupported symlink entry\n") catch {},
error.Storage => s.err.writeAll("zit merge: cannot update working tree: input/output error\n") catch {},
error.TransactionUnavailable => s.err.writeAll("zit merge: cannot create a transaction directory\n") catch {},
error.RollbackFailed => s.err.writeAll(
"zit merge: merge failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
) catch {},
error.CleanupFailed => s.err.writeAll(
"zit merge: merge applied, but the transaction directory could not be removed\n",
) catch {},
error.StateRecordInvalid => s.err.writeAll(
"zit merge: cannot read repository: " ++ update.state_record_invalid_reason ++ "\n",
) catch {},
error.Corrupt,
error.InvalidCheckinId,
error.CheckinNotFound,
error.CheckinContentMissing,
error.CheckinNameMismatch,
error.AggregateChecksumMismatch,
error.TargetNotManifest,
error.BaselineNotFound,
error.BaselineContentMissing,
error.BaselineNameMismatch,
error.BaselineNotManifest,
error.BaselineIsDelta,
=> s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {},
}
return .failure;
}
// ==========================================================================
// `zit revert` — restore managed files to their content at the current
// check-in (artifact §8.4 effective file + §7.2 verified blob). A thin consumer
// of the Repository read path (`fileAt`/`blob`) plus `update.restoreFile`: no
// format work, no managed-set mutation. Content-restore only.
// ==========================================================================
/// `zit revert [PATH...]` — restore managed files to their checked-in content.
/// With no paths, every managed file; with paths, only the named managed files.
pub fn runRevert(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
const parsed = switch (cli.parseRevertArgs(gpa, verb_args) catch return revertOom(streams)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "revert", .unknown_option, tok),
};
defer gpa.free(parsed.paths);
if (parsed.help) {
streams.out.writeAll(revert_help) catch {};
return .ok;
}
// Discover the root (its absolute path drives path-scoped `repoRelative`),
// then open the read-only derived view.
const root = (cli.discoverRoot(arena, io) catch return revertOom(streams)) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
var repo: Repository = undefined;
repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "revert", e);
defer repo.deinit(gpa);
// §7.5: the current checkout names the check-in whose content is restored;
// an unborn/absent checkout has nothing to revert to.
const rid = repo.currentCheckoutRid() orelse return revertNoCheckout(streams);
// The managed set — read, never written: revert restores content only.
var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
return managedFail(streams, "revert", e);
defer set.deinit();
// Gather the resolved restore targets first, so the undo journal captures
// every path's pre-revert bytes in ONE seam before the first file is
// rewritten (B-undo-2/14). Untracked or unresolvable args are reported here
// and excluded from both the journal image and the restore.
var targets: std.ArrayList([]const u8) = .empty;
defer targets.deinit(gpa);
var had_error = false;
if (parsed.paths.len == 0) {
for (set.iterate()) |path| targets.append(gpa, path) catch return revertOom(streams);
} else {
const cwd_abs = (cli.currentPathAlloc(arena, io) catch return revertOom(streams)) orelse {
streams.err.writeAll("zit revert: cannot determine the current directory\n") catch {};
return .failure;
};
for (parsed.paths) |arg| {
const key = switch (cli.repoRelative(arena, root.abs, cwd_abs, arg) catch return revertOom(streams)) {
.key => |k| k,
.root, .outside => {
revertNotTracked(streams, arg);
had_error = true;
continue;
},
};
if (!set.contains(key)) {
revertNotTracked(streams, key);
had_error = true;
continue;
}
targets.append(gpa, key) catch return revertOom(streams);
}
}
// Every restore target is a managed path (gathered from the set, or gated on
// `set.contains` for an explicit arg) — revert never rewrites an untracked file.
if (std.debug.runtime_safety) {
for (targets.items) |t| std.debug.assert(set.contains(t));
}
// Journal-before-mutate (B-undo-3): the undo image is written and fsynced
// before the first restore. A journal fault aborts with nothing mutated —
// the durability rule forbids restoring without a recoverable pre-image.
if (targets.items.len != 0) {
update.journal.record(gpa, io, root_dir, targets.items) catch |e|
return revertJournalFail(streams, e);
}
for (targets.items) |name| {
if (revertOne(gpa, io, &repo, root_dir, rid, name, streams) == .failed) had_error = true;
}
return if (had_error) .failure else .ok;
}
/// A journal-write failure before revert touches any file — exit 1, nothing
/// mutated. `record` only surfaces `OutOfMemory`/`Storage`; the `else` folds the
/// device faults into the honest I/O message.
fn revertJournalFail(s: cli.Streams, e: update.journal.JournalError) cli.Exit {
switch (e) {
error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {},
else => s.err.writeAll("zit revert: cannot write the undo journal: input/output error\n") catch {},
}
return .failure;
}
/// The per-file revert verdict: content restored, nothing to restore (absent
/// from the effective set — informational, not an error), or a genuine fault.
const RevertOutcome = enum { reverted, skipped, failed };
/// Restore one managed file `name` to its content at `rid`. §8.4 effective
/// lookup: a file absent from the effective set (never present, or a §4.7.6
/// delete marker) has no content to restore — reported and left unchanged,
/// never fabricated (`.skipped`). A read/write fault is `.failed`.
fn revertOne(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
root_dir: std.Io.Dir,
rid: Repository.Rid,
name: []const u8,
streams: cli.Streams,
) RevertOutcome {
const found = repo.fileAt(gpa, rid, name) catch |e| {
revertFileFail(streams, name, e);
return .failed;
};
const card = found orelse {
streams.err.print("zit revert: '{s}' is not in the current check-in; left unchanged\n", .{name}) catch {};
return .skipped;
};
defer Repository.freeFileCard(gpa, card);
std.debug.assert(card.hash != null); // fileAt yields only hash-bearing cards (§4.7.6)
// §7.2: the blob is verified to hash to its F-card id before it lands.
const bytes = (repo.blob(gpa, card.hash.?) catch |e| {
revertFileFail(streams, name, e);
return .failed;
}) orelse {
streams.err.print("zit revert: content for '{s}' is missing; left unchanged\n", .{name}) catch {};
return .failed;
};
defer gpa.free(bytes);
update.restoreFile(io, root_dir, name, bytes, card.mode) catch |e| {
switch (e) {
error.SymlinkUnsupported => streams.err.print(
"zit revert: '{s}' is a symlink, which zit does not support; left unchanged\n",
.{name},
) catch {},
error.Storage => streams.err.print(
"zit revert: cannot restore '{s}': input/output error\n",
.{name},
) catch {},
}
return .failed;
};
streams.out.print("reverted {s}\n", .{name}) catch {};
return .reverted;
}
fn revertOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit revert: out of memory\n") catch {};
return .failure;
}
fn revertNoCheckout(s: cli.Streams) cli.Exit {
s.err.writeAll("zit revert: nothing to revert to (the working checkout is unborn)\n") catch {};
return .failure;
}
fn revertNotTracked(s: cli.Streams, path: []const u8) void {
s.err.print("zit revert: '{s}' is not tracked (use 'zit add' to track it)\n", .{path}) catch {};
}
/// Maps a `Repository.fileAt`/`blob` failure to a per-file diagnostic (no exit
/// code — the caller aggregates). Switches the full `FilesError` set
/// exhaustively; `blob`'s narrower `OpenError` coerces in. Phrasings match
/// `ls`/`cat`.
fn revertFileFail(s: cli.Streams, name: []const u8, e: Repository.FilesError) void {
switch (e) {
error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {},
error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
error.CannotRead => s.err.print("zit revert: cannot read '{s}': input/output error\n", .{name}) catch {},
error.Corrupt => s.err.print("zit revert: cannot read '{s}': repository is corrupt\n", .{name}) catch {},
error.NotManifest => s.err.writeAll("zit revert: not a check-in\n") catch {},
error.BaselineUnresolvable => s.err.writeAll("zit revert: cannot resolve the baseline of a delta check-in\n") catch {},
error.BaselineIsDelta => s.err.writeAll("zit revert: the baseline of a delta check-in is itself a delta\n") catch {},
}
}
// ==========================================================================
// `zit undo` / `zit redo` — single-level undo/redo of the working-file mutators
// (contracts/undo.md B-undo-1..21). Thin wiring: parse (help-only, no
// positionals), discover the root BEFORE any journal read (B-undo-19), then
// drive the `update.journal` swap. All format/slot work lives in `undo.zig`;
// restoration reuses the update layer. No log append, no network.
// ==========================================================================
const UndoKind = enum { undo, redo };
/// `zit undo` — reverse the last sheltered working-file mutation.
pub fn runUndo(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
return runUndoRedo(gpa, io, arena, verb_args, streams, .undo);
}
/// `zit redo` — reapply the change a preceding `zit undo` reversed.
pub fn runRedo(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
return runUndoRedo(gpa, io, arena, verb_args, streams, .redo);
}
/// The shared body: the two verbs differ only in name, help text, which journal
/// slot they consume, and the "nothing to …" phrasing (Ousterhout: one
/// mechanism, not two near-identical copies).
fn runUndoRedo(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
kind: UndoKind,
) cli.Exit {
const verb = switch (kind) {
.undo => "undo",
.redo => "redo",
};
const help_text = switch (kind) {
.undo => undo_help,
.redo => redo_help,
};
// Usage errors are exit 2, stderr-only, and read no journal (B-undo-17/18);
// `-h`/`--help` win over any trailing token and print to stdout (B-undo-16).
const parsed = switch (cli.parseNoPositionalArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, verb, .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, verb, .extra_argument, tok),
};
if (parsed.help) {
streams.out.writeAll(help_text) catch {};
return .ok;
}
// B-undo-19: the repository check precedes any journal read; absent → the
// standard not-a-zit-repository diagnostic, exit 1 (distinct from exit 2).
const root = (cli.discoverRoot(arena, io) catch {
streams.err.print("zit {s}: out of memory\n", .{verb}) catch {};
return .failure;
}) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
(switch (kind) {
.undo => update.journal.undo(gpa, io, root_dir),
.redo => update.journal.redo(gpa, io, root_dir),
}) catch |err| return undoFail(streams, verb, err);
// The single confirmation row goes to stdout (B-undo-20); diagnostics never
// share the stream.
streams.out.print("{s}: working tree restored\n", .{verb}) catch {};
return .ok;
}
/// Renders a `JournalError` to stderr and returns `.failure` (exit 1). `Empty`
/// is the nothing-to-undo/redo case (B-undo-9/10); `Corrupt` the damaged
/// journal (B-undo-11); the rest are honest device/allocation faults.
fn undoFail(s: cli.Streams, verb: []const u8, err: update.journal.JournalError) cli.Exit {
switch (err) {
error.Empty => s.err.print("zit {s}: nothing to {s}\n", .{ verb, verb }) catch {},
error.Corrupt => s.err.print("zit {s}: the undo journal is damaged\n", .{verb}) catch {},
error.Storage => s.err.print("zit {s}: cannot restore working tree: input/output error\n", .{verb}) catch {},
error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
}
return .failure;
}
/// One managed file captured for the check-in, or the reason it cannot be
/// (rendered by the caller so the stderr/exit discipline stays in `runCommit`).
const Capture = union(enum) {
/// The file's F card (hash duped onto the manifest arena).
card: artifact.FCard,
missing, // absent from the working tree (B-commit-10)
/// Present but not a regular file (B-commit-10). The payload is `true` for
/// a symlink, which gets the F-B3 recovery hint instead of the bare
/// refusal — carried here rather than re-stat'd by the renderer.
not_regular: bool,
io_error, // a read/store device failure (B-commit-15)
/// Larger than one artifact frame can hold (`managed.max_tracked_file_bytes`).
/// Distinct from `io_error` because it is neither a device fault nor
/// anything the operator can retry: it is a stated limit, and reporting it
/// as "input/output error" named nothing they had done and suggested a
/// failure that might not recur.
too_large: u64,
oom,
};
/// Stats, reads, hashes, and stores one managed file `key`, returning its F card
/// or the reason it could not be captured. All of `commit`'s working-tree read
/// I/O lives here (design-seams rule). Symlinks are not followed, so a symlink
/// is `not_regular`, never silently resolved. The blob is stored uncompressed
/// (the name is over uncompressed bytes; storage compression is deferred —
/// Scope). Idempotent re-intern: `logstore.put` appends nothing for a blob
/// already present.
fn captureFile(
gpa: std.mem.Allocator,
io: std.Io,
ma: std.mem.Allocator,
root_dir: std.Io.Dir,
log: *logstore.LogStore,
key: []const u8,
base: ?logstore.Rid,
) Capture {
const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .missing;
if (st.kind != .file) return .{ .not_regular = st.kind == .sym_link };
if (!trackedFileSizeAllowed(st.size)) return .{ .too_large = st.size };
const content = root_dir.readFileAlloc(io, key, gpa, .limited(max_tracked_file_bytes)) catch |e|
return switch (e) {
error.OutOfMemory => .oom,
else => .io_error,
};
defer gpa.free(content);
// The on-disk log keys frames by the binary digest (logstore.max_hash_len ==
// 32); the F card carries the 64-hex artifact name (§4.7.1 wire form). Both
// derive from the same content, so naming is unaffected by storage.
var digest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(.sha3_256, content, &name_buf);
// §4.1/§7.2 — store the blob, delta-encoded against the same path's blob in
// the parent check-in when that is materially smaller (else a full §4.1
// container). `base` is a hint only; naming is always over the uncompressed
// bytes, so storage compression/delta never changes HASH.
_ = log.putArtifact(&digest, content, base) catch |e| return switch (e) {
error.OutOfMemory => .oom,
error.Storage => .io_error,
};
const hash = ma.dupe(u8, name) catch return .oom;
const filename = ma.dupe(u8, key) catch return .oom;
// Capture the working-tree executable bit so a chmod +x round-trips into the
// check-in instead of silently reverting to regular (a permission drift that
// otherwise shows as `M` on every diff and never commits). §4.7.6: a perm
// token containing `x` marks the entry executable, and a producer SHOULD emit
// only w/x/l — so an executable file carries the explicit `x` token (needed
// for the mode to survive re-parse via `fileMode`), and a regular file omits
// the token. Non-regular kinds already returned `.not_regular` above, so the
// mode is only ever regular or executable here.
const is_exec = executable(st.permissions);
return .{ .card = .{
.filename = filename,
.hash = hash,
.perm = if (is_exec) "x" else null,
.old_name = null,
.mode = if (is_exec) .executable else .regular,
} };
}
/// `submanifestEmission`'s full result: the §8.1/§6.3 emission decision this
/// commit builds against, plus `submanifest-auto-retune` read from the exact
/// same config frame — one `readConfig`, two decisions, so §6.4's
/// auto-dispatch gate (`commitSubmanifestAdvice`, below) costs no second read.
const EmissionDecision = struct {
emission: zit.submanifest.Emission,
/// The last effective-set shape whose k-cost was settled. Equal shapes need
/// no advisory scan; missing/different shapes must be analyzed again.
settled_cost_shape: ?[32]u8,
/// draft-fossil-submanifests-00 §6.4's `submanifest-auto-retune`: the
/// durable project-operator act authorizing subsequent automatic re-tunes.
/// Default false, matching the config field's own default — a repository
/// that never opted in reads `false` here exactly as it always has.
auto_retune: bool,
};
/// draft-fossil-submanifests-00 §8.1/§6.3 — the emission decision for THIS
/// commit, read from the repository's own config frame.
///
/// §8.1: "A writer MUST NOT emit an S card unless the repository's
/// enable-submanifests setting -- a project-scope boolean, default false -- is
/// true". §6.3: "A writer MUST take k from the pinned setting, MUST NOT derive a
/// value per commit ... on receiving a value outside 1..12 it MUST fall back to
/// emitting flat manifests rather than substitute a parameter of its own, and
/// SHOULD report the fallback to the operator."
///
/// The read is folded here rather than inlined at the call site so a config that
/// cannot be read is one decision, not two: a repository with no config frame,
/// or one whose frame will not decode, is `.off` — the default, the state of
/// every repository predating this draft, and the only safe direction (guessing
/// "enabled" would mint artifacts §8 calls unreadable). `submanifestEmission`
/// makes no output; `reportSubmanifestOutOfRange` preserves the §6.3
/// diagnostic ordering after pre-capture pinned-k validation.
fn submanifestEmission(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
) EmissionDecision {
var oc = (log.readConfig(gpa) catch return .{
.emission = .off,
.settled_cost_shape = null,
.auto_retune = false,
}) orelse return .{
.emission = .off,
.settled_cost_shape = null,
.auto_retune = false,
};
defer oc.deinit(gpa);
const decision = zit.submanifest.emission(.of(oc.config));
return .{
.emission = decision,
.settled_cost_shape = oc.config.submanifest_settled_cost_shape,
.auto_retune = oc.config.submanifest_auto_retune,
};
}
/// draft-fossil-submanifests-00 §6.3's SHOULD-report occurs at the historic
/// post-no-op commit phase, even though its pinned-k decision was read earlier.
fn reportSubmanifestOutOfRange(streams: cli.Streams, decision: zit.submanifest.Emission) void {
if (decision != .out_of_range) return;
streams.err.print(
"zit commit: {s} ({d}; must be {d}..{d}) — emitting a flat manifest. " ++
"Re-pin with 'zit submanifest retune --k N'.\n",
.{
zit.submanifest.out_of_range_report_prefix,
decision.out_of_range,
artifact.min_k,
artifact.max_k,
},
) catch {};
}
/// submanifests §8.1 — record the local seen-submanifest indicator, on the
/// false->true edge only. Appends a config frame carrying every existing field
/// plus the flag (plan E last-frame-wins); a repository already carrying it writes
/// nothing. The caller's `log.commit()` makes it durable with the artifacts it
/// describes, so the indicator and the trees it is about land in one fsync.
/// 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.
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;
}
/// Dialect memory read: has this repository ever stored a delta manifest?
/// Config-absent or unreadable reads as false — the flat default.
fn seenDeltaManifest(gpa: std.mem.Allocator, log: *logstore.LogStore) bool {
var oc = (log.readConfig(gpa) catch return false) orelse return false;
defer oc.deinit(gpa);
return oc.config.seen_delta_manifest;
}
/// Record the seen-delta indicator, on the false->true edge only — the same
/// plan-E last-frame-wins append `setSeenSubmanifest` uses, landing in the
/// same `log.commit()` as the delta manifest it is about.
fn setSeenDelta(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
var oc = log.readConfig(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Storage,
};
var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
defer if (oc) |*c| c.deinit(gpa);
if (cfg.seen_delta_manifest) return;
cfg.seen_delta_manifest = true;
try log.putConfig(cfg);
}
fn setSeenSubmanifest(gpa: std.mem.Allocator, log: *logstore.LogStore) logstore.PutError!void {
var oc = log.readConfig(gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// A config frame that will not decode is not a reason to lose a commit's
// artifacts; the §7.2 gate arms on the setting alone in that case, and
// `zit submanifest` shows the operator the unreadable config.
error.Storage, error.Corrupt => return,
};
defer if (oc) |*c| c.deinit(gpa);
var cfg: logstore.Config = if (oc) |c| c.config else .{ .hash_policy = 0, .default_branch = "", .project_id = "" };
if (cfg.seen_submanifest) return; // already recorded — no frame per commit
cfg.seen_submanifest = true;
try log.putConfig(cfg);
}
/// True when `bytes` still carries an unresolved three-way conflict marker: the
/// `merge3.conflict_sentinel` at the start of a line (file start or right after
/// a LF), exactly as `merge3.writeEnvelope` emits it. The sole consumer of the
/// sentinel outside `merge3` — `zit commit` scans every captured file of a
/// recorded merge and refuses on any hit (B-merge-10).
fn hasConflictMarker(bytes: []const u8) bool {
if (std.mem.startsWith(u8, bytes, merge3.conflict_sentinel)) return true;
return std.mem.indexOf(u8, bytes, "\n" ++ merge3.conflict_sentinel) != null;
}
fn buildFullTree(
gpa: std.mem.Allocator,
entries: []const artifact.FCard,
k: u4,
) error{OutOfMemory}!?artifact.TreeBuild {
var built = try artifact.buildTree(gpa, entries, k, .sha3_256);
if (built.isFlat()) {
built.deinit();
return null;
}
return built;
}
/// `zit commit -m <message> [--user <login>] [-q] [--] [<path>...]` — the thin
/// wiring: parse → discover → load checkout+managed set → capture working-tree
/// files → compose the F-set (`cli.composeFCards`) → assemble+serialize the
/// manifest (`artifact.create`) → append blobs+manifest and fsync the log →
/// advance `.zit-checkout` → render. Durability order (B-commit-15): the log is
/// committed before the checkout advances; a failure between is stated honestly.
pub fn runCommit(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
// 1. Parse (B-commit-1). --help wins over everything (B-commit-18a); an
// unknown/dangling option is a usage error (B-commit-18b).
const parsed = switch (cli.parseCommitArgs(gpa, verb_args) catch
return commitOom(streams)) {
.ok => |p| p,
.unknown_option => |tok| {
// A git user reaches for `--amend`/`--force` to rewrite the last
// check-in, but zit history is immutable (B-commit-20): check-ins are
// never rewritten. Teach the model at the point of error rather than
// deferring the lesson to --help; a bare "unknown option" would read
// as a typo and hide the immutable-history rule. Still a usage error:
// exit 2, stderr only, and the try-help line is kept.
if (std.mem.eql(u8, tok, "--amend") or
std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f"))
{
streams.err.print(
"zit commit: '{s}' is not supported. zit history is immutable — " ++
"check-ins are never rewritten; record a new check-in that " ++
"supersedes the old one.\n",
.{tok},
) catch {};
streams.err.writeAll("try 'zit commit --help'\n") catch {};
return .usage;
}
return cli.usageError(streams, "commit", .unknown_option, tok);
},
};
defer gpa.free(parsed.paths);
if (parsed.help) {
streams.out.writeAll(commit_help) catch {};
return .ok;
}
// 2. A message is required (B-commit-5, a usage error) and must be
// meaningful (B-commit-4, an expected failure) — both before any I/O.
const message = parsed.message orelse {
streams.err.writeAll(
"zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
) catch {};
return .usage;
};
if (std.mem.trim(u8, message, " \t\r\n").len == 0) {
streams.err.writeAll(
"zit commit: aborting — empty check-in message\n",
) catch {};
return .failure;
}
// 3. Resolve the author (B-commit-6): --user, else the environment default.
const author = parsed.user orelse amb.user_env orelse {
streams.err.writeAll(
"zit commit: cannot determine the check-in author; pass --user <login>\n",
) catch {};
return .failure;
};
// 3b. #57 autosync pull: see the freshest DAG before the fork guard asks
// whether the parent is still a leaf. Best-effort — offline commits
// must keep working, so a failure warns and this verb proceeds.
if (!parsed.no_sync) cmd_sync.autosyncPull(gpa, io, arena, streams, "commit");
// 4. Discover the checkout root (B-commit-14) and load the working state.
const root = (cli.discoverRoot(arena, io) catch
return commitOom(streams)) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (checkout.read(io, root_dir, &ck_buf) catch |e|
return checkoutFail(streams, "commit", e)) orelse
return commitCheckoutMissing(streams);
const branch = if (ck.branch.len == 0) "trunk" else ck.branch;
const is_root = ck.version.len == 0;
// A recorded merge parent (from `zit merge`) turns this into a merge commit:
// it emits the two-token P card and clears the record on advance (B-merge-8/
// 9). Empty == no pending merge → the ordinary single-parent path, untouched
// (B-merge-9 negative space). A merge is never a root (it requires a prior
// version), so `is_merge` implies `!is_root`.
const is_merge = ck.merge_parent.len != 0;
// A recorded special-merge op (cherrypick/backout, from `zit merge
// --cherrypick/--backout`) is NOT a merge: the check-in stays SINGLE-parent
// (REV is never a merge parent, B-merge-33) and carries one Q card recording
// the provenance (§4.7.14). `special_op` is the q-sign byte glued onto REV's
// hash; empty means no pending special op → today's path, untouched. A
// special op requires a prior version, so it too implies `!is_root`. At most
// one of `is_merge`/`has_special` is ever set (the merge verb refuses to
// start a second pending operation).
const has_special = ck.special_op.len != 0;
const q_sign: ?artifact.QSign = if (!has_special) null else switch (ck.special_op[0]) {
'+' => .cherrypick,
'-' => .backout,
else => unreachable, // `merge` only ever writes '+' or '-'
};
// Checkout-record consistency the merge verb maintains: a pending merge or
// special op never coexists with an unborn (root) checkout, the two are
// mutually exclusive (`merge` refuses to start a second pending operation),
// and a Q card is minted exactly for a special op.
std.debug.assert(!is_root or (!is_merge and !has_special));
std.debug.assert(!(is_merge and has_special));
std.debug.assert((q_sign != null) == has_special);
// repostate §6.2: a branch-root manifest is a CHILD of the branch point, so
// `--branch` needs a parent to fork from. The root check-in's default-branch
// start is `init`/§6.1's job, not a fork — reject `--branch` on an unborn
// checkout before any capture.
if (is_root and parsed.branch != null) {
streams.err.writeAll(
"zit commit: --branch needs a parent check-in; the first commit starts branch 'trunk'\n",
) catch {};
return .failure;
}
// An empty `--branch` value names no branch (§6.2). Reject it here, with the
// other early arg checks, before any store is opened.
if (parsed.branch) |bname| {
if (bname.len == 0) {
streams.err.writeAll("zit commit: --branch name must not be empty\n") catch {};
return .failure;
}
}
// The branch the new check-in lands on: `--branch` overrides, else the
// parent's branch (trunk when unset). `compose_branch` is the name whose
// §6.2 branch/sym tags this commit emits — always trunk for the root
// (establishing it), the explicit `--branch` for a child that switches, and
// null for a child that stays on its parent's branch (no tag, inherited).
const target_branch = parsed.branch orelse branch;
const compose_branch: ?[]const u8 = if (is_root) target_branch else parsed.branch;
var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
return managedFail(streams, "commit", e);
defer set.deinit();
// 5. Open the log read-write (append blobs + manifest, then fsync).
var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch
return commitLogFail(streams, error.Storage);
defer file.close(io);
const log_st = file.stat(io) catch return commitLogFail(streams, error.Storage);
var fs = storage.FileStorage.init(io, file, log_st.size);
// `hash_kind` 0 is a don't-care on a non-empty file: `open` reads the stored
// kind from the header. The repo was named SHA3-256 by `init` (B-commit-16).
var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e|
return commitLogFail(streams, e);
defer log.deinit(gpa);
// 6. One arena owns every card's storage (F slice, arena copies of each
// captured hash + filename); `create` reads card fields only, so the CLI
// supplies the arena. Content buffers are read on `gpa` and freed per file.
var m_arena = std.heap.ArenaAllocator.init(gpa);
defer m_arena.deinit();
const ma = m_arena.allocator();
// 6b. Branch prep (repostate §6.2), only when `--branch` starts/switches a
// branch. The derived read-only view answers two questions the append
// transaction cannot: does NAME already name an open branch (§6.2 forbids
// re-creating one), and which of the parent's active sym-* tags must this
// commit cancel so the check-in leaves the parent's branch(es). The view
// is opened read-only and freed before any append; the sym names are
// duped onto `ma` so they outlive it. `--tag`/`--close` need no view.
var parent_syms: []const []const u8 = &.{};
if (!is_root) {
var repo: Repository = undefined;
repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "commit", e);
defer repo.deinit(gpa);
// Fork guard (#57; fossil's commit-time warning): a commit whose
// parent already has a same-branch child would silently fork the
// branch. The condition is fossil's — the parent must still be a
// LEAF — so committing onto the older head of an already-forked
// branch stays legal (that head is a leaf too). `--branch` is exempt
// (the check-in leaves the branch, a fork by name), as is
// `--allow-fork` (the deliberate case). No network: this also fires
// for offline divergence that no autosync could have prevented.
if (parsed.branch == null and !parsed.allow_fork) {
if (repo.currentCheckoutRid()) |prid| {
if (!repo.state().isLeaf(prid)) {
const tip = repo.resolve(branch, .checkin) catch null;
if (tip) |trid| {
streams.err.print(
"zit commit: not the tip of branch '{s}' — check-in {s} is newer.\n" ++
"run 'zit update' to join it, or commit with --allow-fork to fork deliberately\n",
.{ branch, repo.hashOf(trid) },
) catch {};
} else {
streams.err.print(
"zit commit: the current check-in is no longer the tip of branch '{s}'.\n" ++
"run 'zit update' to join the tip, or commit with --allow-fork to fork deliberately\n",
.{branch},
) catch {};
}
return .failure;
}
}
}
if (parsed.branch) |bname| {
const open_leaves = repo.state().openLeavesOf(gpa, bname) catch return commitOom(streams);
defer gpa.free(open_leaves);
if (open_leaves.len != 0) {
streams.err.print(
"zit commit: branch '{s}' already exists with an open leaf; commit onto it or choose a new name\n",
.{bname},
) catch {};
return .failure;
}
// The parent's inherited sym-* tags to cancel, minus the one we
// re-establish, duped onto `ma` (the view's interned names die at
// `repo.deinit`).
if (repo.currentCheckoutRid()) |prid| {
const want_sym = std.mem.concat(ma, u8, &.{ sym_prefix, bname }) catch return commitOom(streams);
const entries = repo.state().tagsOf(gpa, prid) catch return commitOom(streams);
defer gpa.free(entries);
parent_syms = selectInheritedSyms(ma, entries, want_sym) catch return commitOom(streams);
}
}
}
// 7. Parent manifest (child only): draft-fossil-submanifests-00 §§5.1–5.2
// require an S-bearing parent to be resolved to its complete F-set
// before carry-forward, no-op, or same-path delta-base decisions. Load it
// before capture so resolver corruption cannot leave a captured blob.
var pm: ?artifact.Manifest = null;
defer if (pm) |*m| m.deinit();
// Read path: the parent is prior (possibly fossil-origin) history, so it is
// parsed with the legacy F-card trailing-separator tolerance.
var pm_normalized: ?[]u8 = null;
defer if (pm_normalized) |b| gpa.free(b);
var parent_bytes: ?[]u8 = null;
defer if (parent_bytes) |b| gpa.free(b);
var baseline_bytes: ?[]u8 = null;
defer if (baseline_bytes) |b| gpa.free(b);
var baseline_manifest: ?artifact.Manifest = null;
defer if (baseline_manifest) |*bm| bm.deinit();
var baseline_normalized: ?[]u8 = null;
defer if (baseline_normalized) |b| gpa.free(b);
var parent_f: []const artifact.FCard = &.{};
var parent_manifest_rid: ?logstore.Rid = null;
// path → parent blob rid: the delta base for the same path this commit.
var parent_blob = std.StringHashMap(logstore.Rid).init(gpa);
defer parent_blob.deinit();
if (!is_root) {
var pdigest_buf: [digest_len]u8 = undefined;
const pdigest = hexToDigest(ck.version, &pdigest_buf) orelse return commitParentCorrupt(streams, "the checkout names it in a form this build cannot decode");
const prid = log.lookup(pdigest) orelse return commitParentCorrupt(streams, "not present in this repository");
parent_manifest_rid = prid;
parent_bytes = (log.content(gpa, prid) catch |e| return commitContentFail(streams, e)) orelse
return commitParentCorrupt(streams, "its content is unreadable");
const parent_im = artifact.parseForImport(gpa, parent_bytes.?) catch |err| switch (err) {
error.OutOfMemory => return commitOom(streams),
else => |pe| return commitParentCorrupt(streams, @errorName(pe)),
};
pm = parent_im.manifest;
pm_normalized = parent_im.normalized;
if (pm.?.kind != .manifest) return commitParentCorrupt(streams, "it is not a check-in manifest");
if (pm.?.s.len != 0) {
Repository.flattenTreeFromLog(gpa, &log, &pm.?) catch |err| switch (err) {
error.OutOfMemory => return commitOom(streams),
// NOT collapsed: a §5.2 tree-walk fault, an unreadable node and
// real damage are different repairs.
else => |te| return commitParentCorrupt(streams, @errorName(te)),
};
}
if (pm.?.b) |b| {
// §8.2/§8.3 — a delta parent's own F run is a PATCH, not a file
// set. Whole-tree capture masks that on the default path (every
// managed file is captured regardless), but carry-forward and
// the delta-frame base hints read `parent_f` as the parent's
// EFFECTIVE set, and the dialect emission below diffs against
// the true baseline — so resolve it, hard-failure discipline,
// and retain the raw baseline beside the merged result.
var bdigest_buf: [digest_len]u8 = undefined;
const bdigest = hexToDigest(b.hash, &bdigest_buf) orelse return commitParentCorrupt(streams, "its baseline is named in a form this build cannot decode");
const brid = log.lookup(bdigest) orelse return commitParentCorrupt(streams, "its baseline is not present in this repository");
baseline_bytes = (log.content(gpa, brid) catch |e| return commitContentFail(streams, e)) orelse
return commitParentCorrupt(streams, "its baseline content is unreadable");
const bim = artifact.parseForImport(gpa, baseline_bytes.?) catch |err| switch (err) {
error.OutOfMemory => return commitOom(streams),
else => |pe| return commitParentCorrupt(streams, @errorName(pe)),
};
baseline_manifest = bim.manifest;
baseline_normalized = bim.normalized;
if (baseline_manifest.?.kind != .manifest or baseline_manifest.?.b != null)
return commitParentCorrupt(streams, "its baseline is not a baseline manifest");
const BaselineCtx = struct {
bm: *const artifact.Manifest,
fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const self_ctx: *@This() = @ptrCast(@alignCast(ctx));
_ = hash; // one candidate, already matched to the B card above
return zit.manifestFilesOf(self_ctx.bm);
}
};
var bctx = BaselineCtx{ .bm = &baseline_manifest.? };
var walk = artifact.enumerate(zit.manifestFilesOf(&pm.?), .{
.ctx = @ptrCast(&bctx),
.resolveFn = BaselineCtx.resolve,
}) catch return commitParentCorrupt(streams, "its baseline does not enumerate");
var effective: std.ArrayList(artifact.FCard) = .empty;
while (walk.next()) |fc| {
if (fc.hash == null) continue; // merged delete markers carry no file
effective.append(ma, fc) catch return commitOom(streams);
}
parent_f = effective.items;
} else {
parent_f = pm.?.flattened orelse pm.?.f;
}
// Sparse-cone RFC section 4.4: a pending cross-cone merge left its
// resolved out-of-cone outcomes in the merge_overlay section. Apply
// them onto the parent's effective set BEFORE anything reads it --
// the compose's carry-forward then records theirs' hashes, and the
// base-hint loop below sees the overlaid cards too. The section is
// cleared by the post-commit record write, same one-shot rule as
// merge_parent itself.
{
const overlay_payload = checkout.readSectionAlloc(gpa, io, root_dir, .merge_overlay) catch |e| switch (e) {
error.OutOfMemory => return commitOom(streams),
else => return commitLogFail(streams, error.Storage),
};
if (overlay_payload) |payload| {
defer gpa.free(payload);
// Decoded entries BORROW the payload; the merged cards below
// borrow the entries. Re-home the bytes in the commit arena
// so everything outlives this block together.
const payload_owned = ma.dupe(u8, payload) catch return commitOom(streams);
const overlay_entries = update.decodeOverlay(ma, payload_owned) catch
return commitParentCorrupt(streams, "the pending merge's overlay record is unreadable");
var merged: std.ArrayList(artifact.FCard) = .empty;
for (parent_f) |fc| {
var replaced = false;
var deleted = false;
for (overlay_entries) |entry| {
if (!std.mem.eql(u8, entry.path, fc.filename)) continue;
if (entry.hash) |h| {
var card = fc;
card.hash = h;
card.perm = entry.perm;
merged.append(ma, card) catch return commitOom(streams);
replaced = true;
} else {
deleted = true;
}
break;
}
if (!replaced and !deleted) merged.append(ma, fc) catch return commitOom(streams);
}
// Overlay entries for paths the parent lacks are additions.
for (overlay_entries) |entry| {
const h = entry.hash orelse continue;
var known = false;
for (parent_f) |fc| {
if (std.mem.eql(u8, entry.path, fc.filename)) {
known = true;
break;
}
}
if (!known) merged.append(ma, .{
.filename = entry.path,
.hash = h,
.perm = entry.perm,
.old_name = null,
.mode = .regular,
}) catch return commitOom(streams);
}
std.mem.sort(artifact.FCard, merged.items, {}, struct {
fn less(_: void, a: artifact.FCard, b: artifact.FCard) bool {
return artifact.compareBytes(a.filename, b.filename) == .lt;
}
}.less);
parent_f = merged.items;
}
}
// A parent F card whose blob resolves in the log maps its path to that
// blob's rid; a card without a resolvable blob is simply absent (its
// child blob then stores full — the base hint is optional).
for (parent_f) |fc| {
const h = fc.hash orelse continue;
var bd_buf: [digest_len]u8 = undefined;
const bd = hexToDigest(h, &bd_buf) orelse continue;
const brid = log.lookup(bd) orelse continue;
parent_blob.put(fc.filename, brid) catch return commitOom(streams);
}
}
// 8. Capture the working-tree files and pick the merge policy (B-commit-8/
// 10/11/12). This is the only place the all-files/partial split is
// decided; all merge/sort/no-op arithmetic is deferred to composeFCards.
var captured: std.ArrayList(artifact.FCard) = .empty;
const policy: cli.ComposePolicy = if (parsed.paths.len == 0) .snapshot else .carry_forward;
if (parsed.paths.len == 0) {
// All-files: capture every managed path (a deletion is simply absent
// from the set → not captured → dropped by composeFCards, B-commit-12).
for (set.iterate()) |path| {
switch (captureFile(gpa, io, ma, root_dir, &log, path, parent_blob.get(path))) {
.card => |c| captured.append(ma, c) catch return commitOom(streams),
.missing => return commitMissing(streams, path),
.not_regular => |is_symlink| return commitNotRegular(streams, path, is_symlink),
.io_error => return commitCaptureIo(streams, path),
.too_large => |size| return commitTooLarge(streams, path, size),
.oom => return commitOom(streams),
}
}
} else {
// Partial: capture only the named managed files (B-commit-11). The
// unnamed parent files are carried forward by composeFCards, not here.
const cwd_abs = (cli.currentPathAlloc(arena, io) catch
return commitOom(streams)) orelse
return commitLogFail(streams, error.Storage);
for (parsed.paths) |arg| {
const key = switch (cli.repoRelative(arena, root.abs, cwd_abs, arg) catch
return commitOom(streams)) {
.key => |k| k,
.root, .outside => return commitNotTracked(streams, arg),
};
if (!set.contains(key)) return commitNotTracked(streams, key);
switch (captureFile(gpa, io, ma, root_dir, &log, key, parent_blob.get(key))) {
.card => |c| captured.append(ma, c) catch return commitOom(streams),
.missing => return commitMissing(streams, key),
.not_regular => |is_symlink| return commitNotRegular(streams, key, is_symlink),
.io_error => return commitCaptureIo(streams, key),
.too_large => |size| return commitTooLarge(streams, key, size),
.oom => return commitOom(streams),
}
}
}
// 8b. Stamp the pending rename (§4.7.6): if `zit mv` recorded a rename and
// NEW was captured this commit, mark that card with OLD as the fourth
// (old-name) token plus an explicit permission token — the grammar keeps
// old-name in the fourth field only when a perm token precedes it, and
// §4.7.6 SHOULD-w for a regular file (an executable keeps its `x`). The
// card is captured under NEW, so composeFCards sorts it under NEW (§4.6)
// and the perm/old_name fields ride through the sort/no-op logic
// untouched. `carried` gates the one-shot clear (step 12b): a partial
// commit that does not name NEW leaves the record pending (B-mv-8). OLD
// is duped onto `ma` so the card never aliases the record freed at clear.
var carried = false;
if (set.pendingRename()) |rn| {
for (captured.items) |*c| {
if (std.mem.eql(u8, c.filename, rn.new)) {
c.old_name = ma.dupe(u8, rn.old) catch return commitOom(streams);
c.perm = c.perm orelse "w";
carried = true;
break;
}
}
}
// Managed-set agreement: every captured card names a path in the managed set
// (all-files iterates it; partial gates each arg on `set.contains`), so the
// manifest assembles only over tracked files.
if (std.debug.runtime_safety) {
for (captured.items) |c| std.debug.assert(set.contains(c.filename));
}
// 9. Compose the F-set and map the no-op verdict (B-commit-9). Guarded
// before any durable write — nothing is committed on a no-op. A rename
// always makes NEW differ from the parent's names, so it is never a no-op.
// Sparse-cone RFC section 4.2: a commit from a sparse checkout unions the
// in-cone worktree state with the baseline's out-of-cone entries carried
// verbatim. The reader-indistinguishability rule is the point: the minted
// check-in is ordinary, and no reader can tell it was committed sparsely.
var commit_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) {
error.OutOfMemory => return commitOom(streams),
else => return commitLogFail(streams, error.Storage),
};
defer if (commit_cone) |*c| c.deinit(gpa);
const composed = cli.composeFCards(ma, .{
.parent = parent_f,
.captured = captured.items,
.policy = policy,
.sparse = if (commit_cone) |*c| c else null,
}) catch return commitOom(streams);
// A pending merge / cherrypick / backout is itself the change: a convergent
// merge (both sides made the same edit) composes to F cards identical to the
// parent's, so `unchanged` goes true even though a second parent (P card) or
// a provenance record (Q card) is queued. Refusing there wedges the tree with
// `undo` as the only escape and silently drops the merge parent (blocker B5),
// so the no-op verdict applies only to ordinary commits.
if (composed.unchanged and !is_merge and !has_special) {
if (is_root)
streams.err.writeAll(
"zit commit: nothing to commit (no tracked files) — use 'zit add <file>'\n",
) catch {}
else
streams.err.writeAll("zit commit: nothing to commit (no changes)\n") catch {};
return .failure;
}
// 9b. A recorded merge must not be committed while any file it captures still
// carries an unresolved conflict marker (B-merge-10). Re-read each
// captured working-tree file and scan for `merge3.conflict_sentinel` at a
// line start; any hit refuses (exit 1, offending path(s) to stderr) with
// no check-in minted and the merge parent still recorded — there is no
// override flag; in-tree resolution or `zit undo` is the only way forward.
// Gated on `is_merge`/`has_special`, so an ordinary commit reads nothing
// extra and its bytes/I/O are unperturbed. Placed before any durable log
// write, so a refusal commits nothing. A cherrypick/backout conflict is
// surfaced identically (B-merge-35, the conflict model verbatim).
if (is_merge or has_special) {
var unresolved: usize = 0;
for (captured.items) |c| {
const cbytes = root_dir.readFileAlloc(io, c.filename, gpa, .limited(max_tracked_file_bytes)) catch |e|
return switch (e) {
error.OutOfMemory => commitOom(streams),
else => commitCaptureIo(streams, c.filename),
};
defer gpa.free(cbytes);
if (hasConflictMarker(cbytes)) {
streams.err.print("zit commit: unresolved conflict in {s}\n", .{c.filename}) catch {};
unresolved += 1;
}
}
if (unresolved != 0) {
streams.err.print(
"zit commit: {d} unresolved conflict(s); resolve and commit, or run 'zit undo'\n",
.{unresolved},
) catch {};
return .failure;
}
}
// 9c. submanifests §6.2 canonical tree construction, behind the §8.1 gate.
// Default OFF: the setting is `false` on every repository that has not
// been opted in by an operator act (`zit submanifest enable`), so this
// branch does not run and every check-in this verb mints keeps today's
// bytes exactly. §8.2 forbids enabling it on any repository that is not a
// disposable test fixture. What the branch owns is the library's storage
// contract: `subs` arrives in the order §6.2 requires ("Sub-manifest
// artifacts are stored before the manifest that references them"), so
// storing it front to back is the whole obligation — no heights, groups,
// or keys cross the boundary.
var tree: ?artifact.TreeBuild = null;
defer if (tree) |*t| t.deinit();
var r_hex: [32]u8 = undefined;
var r_card: ?artifact.RCard = null;
const emission_decision = submanifestEmission(gpa, &log);
const emission_k: ?u4 = emission_decision.emission.treeK();
reportSubmanifestOutOfRange(streams, emission_decision.emission);
if (emission_k) |k| {
tree = buildFullTree(gpa, composed.f, k) catch return commitOom(streams);
if (tree != null) {
for (tree.?.subs) |sub| {
var sdigest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(sub.bytes, &sdigest, .{});
// No delta base: a sub-manifest is content-addressed and shared
// across check-ins, so there is no "same path in the parent" to
// delta against the way a blob or a manifest has.
_ = log.putArtifact(&sdigest, sub.bytes, null) catch |e| return commitLogFail(streams, e);
}
// submanifests §8.1 — "When the first S-bearing artifact is stored,
// an implementation SHOULD record a local seen-submanifest indicator
// so operational surfaces can warn before the setting is enabled or
// a pre-S client is granted access." The sync receive path already
// does this at its own persist boundary (`persistStore`); this is the
// other place S-bearing artifacts enter a log, and it is the one that
// matters most — a repository whose own commits mint trees must arm
// the §7.2 transfer gate even after the operator disables the setting
// again, since §8.1's "Disabling ... cannot retire existing ones".
// Written only on the false->true edge, so a steady-state enabled
// repository appends no extra config frame per commit.
setSeenSubmanifest(gpa, &log) catch |e| return commitLogFail(streams, e);
}
}
// 9d. The R aggregate content checksum, EVERY shape (§6.3; Fossil emits it
// by default). Flat: over the manifest's own F run. Tree (§5.5): "the R
// card MUST be computed over the walk enumeration of Section 5.1 ...
// after which the computation of [FossilArtifact], Section 6.3.2 applies
// byte-for-byte unchanged" — and for a freshly constructed tree that
// enumeration is `composed.f` in order, the very list the construction
// partitioned. One computation serves both shapes.
//
// The content comes from the LOG, keyed by the card's own hash, not
// from a second read of the working tree. R exists to detect an F run
// that disagrees with the content it names, so computing the two from
// different sources defeats it: a file edited between capture (step 8)
// and here would mint a check-in whose R card contradicts its own F
// hashes. Every entry is present — captured this commit or carried
// forward from the parent — so a miss is a corrupt repository, and it
// costs no extra working-tree I/O.
{
var agg: artifact.ChecksumAggregator = .{};
var r_computable = true;
for (composed.f) |f| {
const fhash = f.hash orelse return commitParentCorrupt(streams, "a parent F card carries no hash");
var fdigest_buf: [digest_len]u8 = undefined;
const fdigest = hexToDigest(fhash, &fdigest_buf) orelse return commitParentCorrupt(streams, "a parent F card names a blob in an undecodable form");
const frid = log.lookup(fdigest) orelse {
// Sparse-cone RFC section 4.3: over a SPARSE store an elided
// carried-forward blob makes the aggregate uncomputable, and
// the commit MUST omit R rather than fabricate or fail. The
// absence must be the STORE cone's doing -- a dense store
// missing a parent blob is still the corruption it always was.
var probe = zit.repocache.readStoreCone(gpa, io, root_dir);
if (probe) |*c| {
const covered = c.covers(f.filename);
c.deinit(gpa);
if (!covered) {
r_computable = false;
break;
}
}
return commitParentCorrupt(streams, "a parent F card names a blob this repository lacks");
};
const fbytes = (log.content(gpa, frid) catch |e| return commitContentFail(streams, e)) orelse
return commitParentCorrupt(streams, "a parent F card's blob is unreadable");
defer gpa.free(fbytes);
agg.update(f.filename, fbytes);
}
if (r_computable) {
r_card = .{ .checksum = agg.final(&r_hex) };
}
}
// 9e. Dialect continuation: once this repository carries delta manifests
// (a fossil-imported history, or an earlier delta commit here), keep
// emitting them — a multi-megabyte flat manifest in a delta-dialect
// store changes the repository's shape and bloats it. The baseline is
// the parent when the parent is flat, or the parent's OWN baseline
// when it is a delta (§4.7.3: never a delta of a delta). Skipped for
// tree emission (§4.4: B and S never mix), for roots, and whenever
// the patch would not be smaller than the flat F run.
var delta_b: ?artifact.BCard = null;
var delta_f: []const artifact.FCard = &.{};
if (tree == null and !is_root and pm.?.s.len == 0 and
(pm.?.b != null or seenDeltaManifest(gpa, &log)))
dialect: {
const base_raw: []const artifact.FCard = if (pm.?.b != null) baseline_manifest.?.f else pm.?.f;
const base_hash: []const u8 = if (pm.?.b) |b| b.hash else ck.version;
var cards: std.ArrayList(artifact.FCard) = .empty;
var ci: usize = 0;
var bi: usize = 0;
while (ci < composed.f.len or bi < base_raw.len) {
const order: std.math.Order = if (ci >= composed.f.len)
.gt
else if (bi >= base_raw.len)
.lt
else
artifact.compareBytes(composed.f[ci].filename, base_raw[bi].filename);
switch (order) {
.lt => {
cards.append(ma, composed.f[ci]) catch return commitOom(streams);
ci += 1;
},
.gt => {
// Present in the baseline, absent now: a delete marker
// (an F card with no hash, §4.7.6).
cards.append(ma, .{
.filename = base_raw[bi].filename,
.hash = null,
.perm = null,
.old_name = null,
.mode = .regular,
}) catch return commitOom(streams);
bi += 1;
},
.eq => {
const c = composed.f[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) cards.append(ma, c) catch return commitOom(streams);
ci += 1;
bi += 1;
},
}
}
// The size guard, 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 emit flat and leave the dialect to the next commit.
var delta_bytes: usize = 0;
for (cards.items) |c| delta_bytes += fCardCost(c);
var flat_bytes: usize = 0;
for (composed.f) |c| flat_bytes += fCardCost(c);
if (delta_bytes >= flat_bytes) break :dialect;
delta_b = .{ .hash = base_hash };
delta_f = cards.items;
// First delta authored HERE (parent-dialect fallback on a store whose
// config predates the indicator): make the memory durable alongside.
setSeenDelta(gpa, &log) catch |e| return commitLogFail(streams, e);
}
// 10. Assemble the manifest and serialize it (B-commit-17). The library owns
// the Z self-check, card order, escaping, and framing. Root establishes
// `trunk` via two propagating T cards (B-commit-13); a child carries one
// P card and no T card (inherits trunk by propagation).
var ts_buf: [19]u8 = undefined;
const ts = cli.formatWriterTimestamp(amb.now_epoch, &ts_buf);
// Control tags (repostate §6.2 branch creation + artifact §4.7 close).
// The root establishes trunk (two propagating tags — `--branch` is rejected
// on the root); a child emits branch/sym/cancel tags only when `--branch`
// switches it, plus `+closed` on `--close`. In the common no-flag child case
// this is an empty slice, preserving today's bytes exactly. (`--tag` is
// deferred behind the propagating-vs-singleton ambiguity, so it is absent.)
const tags = cli.composeCommitTags(ma, .{
.branch = compose_branch,
.close = parsed.close,
.parent_syms = parent_syms,
}) catch return commitOom(streams);
// A recorded merge extends the single primary parent to a two-token P card
// (B-merge-8): `P <primary> <merge-parent>`, primary ancestor first (§4.7.13
// / repostate §3.2 first-listed-is-primary), SP-joined so `appendPCard`
// renders both tokens verbatim. The non-merge case keeps `count == 1` and
// `parents == ck.version`, so today's manifest bytes are byte-identical.
const parents_field: []const u8 = if (is_merge)
std.mem.concat(ma, u8, &.{ ck.version, " ", ck.merge_parent }) catch return commitOom(streams)
else
ck.version;
const child_parents = [_]artifact.PCard{.{
.parents = parents_field,
.count = if (is_merge) 2 else 1,
}};
// A recorded special-merge op mints exactly one Q card (§4.7.14, B-merge-33):
// `Q +<rev>` (cherrypick) or `Q -<rev>` (backout), the sign glued onto the
// target hash, baseline token OMITTED — the omitted-baseline default IS the
// primary parent the merge used, so emitting it would be redundant. The
// target borrows `ck.special_op` (which outlives `create`, exactly as the P
// card borrows `ck.version`). The non-special case keeps an empty `q` slice,
// so today's manifest bytes are byte-identical (B-merge-31).
var q_cards: [1]artifact.QCard = undefined;
const q_slice: []const artifact.QCard = if (q_sign) |s| blk: {
q_cards[0] = .{ .sign = s, .target = ck.special_op[1..], .baseline = null };
break :blk &q_cards;
} else &.{};
// Checkout-record agreement: a child's parent version resolved to a stored
// manifest (its rid feeds the delta base and the P card); only a root has none.
std.debug.assert(is_root or parent_manifest_rid != null);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = delta_b,
.c = .{ .comment = message },
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = r_card,
.u = .{ .login = author },
.z = null,
.w = null,
// submanifests §4.4: the file set is conveyed by the F run or the S run,
// "never a mixture". With the gate off `tree` is null, so this is
// `composed.f` and an empty S run — not one byte of difference.
.f = if (tree != null) &.{} else if (delta_b != null) delta_f else composed.f,
.j = &.{},
.m = &.{},
.p = if (is_root) &.{} else &child_parents,
.q = q_slice,
.s = if (tree) |t| t.s else &.{},
.t = tags,
};
defer m.deinit(); // frees the empty arena_state; card storage is m_arena's
const bytes = artifact.create(&m, gpa) catch return commitOom(streams);
defer gpa.free(bytes);
// Correctness self-check (safety builds only): the emitted bytes round-trip
// to a manifest. A failure means the CLI mis-populated a card — a bug caught
// here, never shipped.
if (std.debug.runtime_safety) {
var check = artifact.parse(gpa, bytes) catch |e|
std.debug.panic("commit produced an unparseable manifest: {s}", .{@errorName(e)});
defer check.deinit();
std.debug.assert(check.kind == .manifest);
}
// 11. Store the manifest and durably commit the log (B-commit-15 order). The
// frame is keyed by the binary digest; the reported name is the 64-hex.
var mdigest: [digest_len]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{});
var mname_buf: [64]u8 = undefined;
const manifest_name = artifact.artifactName(.sha3_256, bytes, &mname_buf);
// §4.1/§7.2 — store the manifest, delta-encoded against the primary parent
// manifest when materially smaller (else a full §4.1 container). The
// reported name is the 64-hex of the UNCOMPRESSED manifest, so
// delta/compression is address-transparent.
_ = log.putArtifact(&mdigest, bytes, parent_manifest_rid) catch |e| return commitLogFail(streams, e);
log.commit() catch |e| return commitLogFail(streams, e);
// 12a. Clear the pending rename now that the check-in carrying it is durable
// (B-mv-8, one-shot: consumed only by the commit that named NEW). Order
// is log-durable → managed-clear (fsync) → checkout-advance, so a crash
// between stages leaves a recoverable tree (B-commit-15 discipline). The
// no-rename common case skips this entirely, so today's bytes and I/O
// are unperturbed (B-mv-9).
if (carried) {
set.clearRename();
set.write(io, root_dir) catch |e| return managedFail(streams, "commit", e);
}
// 12. Advance the working state — only after the log is durable. The single
// non-atomic seam (B-commit-15): a failure here leaves the check-in in
// history but the working version un-advanced; a re-run re-parents from
// the old version (the durable log is never corrupted).
// The pending merge OR special-merge op (if any) is consumed by this
// check-in, so the advanced checkout clears BOTH (`merge_parent = ""`,
// `special_op = ""`, B-merge-9/34 one-shot clear).
{
var post_ck_buf: [checkout.max_ck_payload]u8 = undefined;
const post_ck = checkout.encodeCk(&post_ck_buf, .{ .version = manifest_name, .branch = target_branch, .merge_parent = "", .special_op = "", .manifest_flags = "" });
checkout.updateSections(io, root_dir, &.{
.{ .tag = .checkout, .payload = post_ck },
// The overlay is consumed by this check-in exactly as the pending
// merge is: one-shot, cleared in the same splice.
.{ .tag = .merge_overlay, .payload = null },
}) catch return commitCheckoutWriteFail(streams);
}
// 13. Success (B-commit-2/3/19): the one check-in line to stdout, the full
// artifact hash as a fixed-position token, on the branch it landed on.
// `-q` suppresses it.
if (!parsed.quiet) {
streams.out.print("check-in {s} on {s}\n", .{ manifest_name, target_branch }) catch {};
}
// 13b. #57 autosync push: the new check-in reaches the saved remote
// immediately, fossil's collaboration default. Strictly after the
// success line — the local commit is already true — and void like the
// advisory below: no autosync outcome can change this verb's exit.
if (!parsed.no_sync) cmd_sync.autosyncPush(gpa, io, arena, streams, "commit", amb);
// 14. submanifests §6.4 post-commit drift advisory — strictly after the
// check-in line above, never before it and never interleaved with the
// commit itself: `commitSubmanifestAdvice` returns void, so it is
// structurally incapable of changing this verb's exit code.
commitSubmanifestAdvice(
gpa,
io,
root_dir,
streams,
composed.f,
emission_k,
emission_decision.settled_cost_shape,
emission_decision.auto_retune,
parsed.no_submanifest_advice,
);
return .ok;
}
/// Renders `zit commit: out of memory` and returns `.failure` (B-commit-15).
fn commitOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit commit: out of memory\n") catch {};
return .failure;
}
/// draft-fossil-submanifests-00 §6.4's post-commit drift advisory: a re-tune
/// "MUST be manual and operator-initiated unless automatic re-tuning is
/// authorized below", and "A writer MAY re-tune automatically only while the
/// repository's submanifest-auto-retune setting -- a project-scope boolean,
/// default false -- is true ... setting it true is the durable
/// project-operator act authorizing subsequent automatic re-tunes." With
/// `auto_retune` false — the config field's own default, so every repository
/// predating this slice reads it as such — the two arms below are
/// byte-identical to the manual-only path: the pin moves only after an
/// explicit 'y' typed at the prompt, never automatically.
///
/// Called from `runCommit` exactly once, strictly after the check-in is
/// durably recorded (never before, never interleaved with it) — `void` return
/// makes it structurally impossible for anything here to change the commit's
/// own exit code. Any failure along the way (allocator failure, a failed prompt
/// read, a failed write) simply stops and prints nothing further: the commit
/// already succeeded, and a broken advisory is not worth confusing that success.
///
/// `pinned_k` is `runCommit`'s own `emission_k` — the same `.treeK()`-derived
/// "enabled AND pinned-in-range" value step 9c already computed for the tree
/// build, so a repository that never turned submanifests on (`pinned_k ==
/// null`) reaches here and returns immediately, at zero added cost: no config
/// read, no `driftCheck` scan, no output (requirement 1). `no_advice` is
/// `--no-submanifest-advice`; when set, step 3 onward (the `driftCheck` scan
/// itself included) never runs (requirement 2), regardless of `auto_retune` —
/// an explicit per-commit "say nothing" wins over the durable opt-in.
/// `auto_retune` is `runCommit`'s own `emission_decision.auto_retune`, read
/// from the same config frame `pinned_k` was derived from.
fn commitSubmanifestAdvice(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
entries: []const artifact.FCard,
pinned_k: ?u4,
settled_cost_shape: ?[32]u8,
auto_retune: bool,
no_advice: bool,
) void {
if (no_advice) return;
const k = pinned_k orelse return;
// `entries` is `runCommit`'s exact canonical final F-set: §6.4 advice runs
// after durability, but it must not reopen/flatten the checkout merely to
// rediscover the same §6.2 tree input.
const shape = submanifestCostShapeCheck(entries, settled_cost_shape);
if (shape.settled) return;
const drift = (zit.submanifest.driftCheck(gpa, entries, k, .sha3_256) catch return) orelse {
_ = submanifestWrite(
gpa,
io,
root_dir,
null,
null,
replaceSettledCostShape(shape.digest),
streams,
);
return;
};
// §6.4's advisory line — exact wording, one line, stdout. Printed
// regardless of `auto_retune`: §6.4's automatic path still advises, it
// just skips the prompt that would otherwise gate the write.
streams.out.print(
"submanifest-k has drifted from {d} to {d} ({d:.1}% read-cost improvement)\n",
.{ k, drift.recommended.k, drift.margin_pct },
) catch return;
if (auto_retune) {
if (!submanifestWrite(
gpa,
io,
root_dir,
null,
drift.recommended.k,
replaceSettledCostShape(shape.digest),
streams,
)) return;
streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {};
streams.out.writeAll(
"note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
"leaf, once. No history is rewritten and no reader behaves differently.\n",
) catch {};
return;
}
if (!promptRetune(streams)) return;
if (!submanifestWrite(
gpa,
io,
root_dir,
null,
drift.recommended.k,
replaceSettledCostShape(shape.digest),
streams,
)) return;
streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {};
streams.out.writeAll(
"note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
"leaf, once. No history is rewritten and no reader behaves differently.\n",
) catch {};
}
const SubmanifestCostShapeCheck = struct {
digest: [32]u8,
settled: bool,
};
/// Internal cache decision only: §6.4 still governs advisory timing and output.
/// It needs only the already-composed F-set, never a repository read.
fn submanifestCostShapeCheck(
entries: []const artifact.FCard,
settled_cost_shape: ?[32]u8,
) SubmanifestCostShapeCheck {
var digest: [32]u8 = undefined;
const current = zit.submanifest.costShapeDigest(entries, &digest);
return .{
.digest = digest,
.settled = if (settled_cost_shape) |cached| std.mem.eql(u8, cached[0..], current) else false,
};
}
test "commit submanifest advice §6.4: settled equal shape needs no repository read" {
const entries = [_]artifact.FCard{
.{ .filename = "a", .hash = null, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "b", .hash = null, .perm = null, .old_name = null, .mode = .regular },
};
const initial = submanifestCostShapeCheck(&entries, null);
const cached = submanifestCostShapeCheck(&entries, initial.digest);
try std.testing.expect(cached.settled);
}
/// The §6.4 drift-advisory confirm prompt: `retune now? [y/N] ` on stderr
/// (the same stream `readPasswordNoEcho` prompts on, `cmd/sync.zig`), one line
/// read from stdin, true only when its first byte is `y`/`Y`.
///
/// `tcgetattr(STDIN_FILENO)` success/failure is the sole interactivity signal,
/// checked BEFORE printing anything — matching `readPasswordNoEcho`'s own
/// precedent for "is this interactive" rather than inventing a second one for
/// this single call site. On a non-terminal stdin (every automated gate) this
/// returns false without printing the prompt or attempting a read, which is
/// what makes the non-interactive/advisory-only path exercised by ordinary
/// commit tests for free.
///
/// Not exercised by the automated gates (they never run against a real tty)
/// — this is the interactive path, same honesty `readPasswordNoEcho`'s own
/// doc comment states about itself.
fn promptRetune(streams: cli.Streams) bool {
const fd = std.posix.STDIN_FILENO;
_ = std.posix.tcgetattr(fd) catch return false;
streams.err.writeAll("retune now? [y/N] ") catch return false;
streams.err.flush() catch return false;
var buf: [8]u8 = undefined;
const n = std.posix.read(fd, &buf) catch return false;
if (n == 0) return false;
return buf[0] == 'y' or buf[0] == 'Y';
}
/// The checkout record vanished between discovery and read (B-commit-15).
fn commitCheckoutMissing(s: cli.Streams) cli.Exit {
s.err.writeAll("zit commit: cannot read checkout state: .zit-checkout is missing\n") catch {};
return .failure;
}
/// Renders a B-commit-15 log open/put/commit failure. Exhaustive over the widest
/// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError`
/// coerce into it, so one mapper serves all three.
fn commitLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {},
error.Storage => s.err.writeAll(
"zit commit: cannot write the check-in: input/output error\n",
) catch {},
error.Corrupt => s.err.writeAll("zit commit: the repository is corrupt\n") catch {},
}
return .failure;
}
/// Renders a B-commit-15 parent-manifest content failure. A `Corrupt` frame is
/// the parent-corrupt case; exhaustive over `logstore.ContentError`.
fn commitContentFail(s: cli.Streams, err: logstore.ContentError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {},
error.Storage => s.err.writeAll(
"zit commit: cannot write the check-in: input/output error\n",
) catch {},
error.Corrupt => s.err.writeAll("zit commit: the parent check-in is corrupt\n") catch {},
}
return .failure;
}
/// The parent check-in named by the checkout could not be found or parsed
/// (B-commit-15). A missing parent frame or a malformed manifest is corruption.
///
/// `why` names WHICH step failed. Every one of these used to print the same
/// sentence, so a name this build could not decode, a parent absent from the
/// log, and genuine damage were indistinguishable at the CLI -- which is how
/// `zit verify` could report a store clean while `commit` called its parent
/// corrupt, with no way to tell from the outside which one was wrong (#24).
fn commitParentCorrupt(s: cli.Streams, why: []const u8) cli.Exit {
s.err.print("zit commit: the parent check-in is corrupt ({s})\n", .{why}) catch {};
return .failure;
}
/// The log committed but the working state could not advance (B-commit-15) — the
/// single non-atomic seam, stated honestly.
fn commitCheckoutWriteFail(s: cli.Streams) cli.Exit {
s.err.writeAll(
"zit commit: the check-in was recorded but the working state could not be updated: input/output error\n",
) catch {};
return .failure;
}
/// A managed/selected file is absent from the working tree (B-commit-10). Nothing
/// is written — the failure is raised before `logstore.commit`.
fn commitMissing(s: cli.Streams, path: []const u8) cli.Exit {
s.err.print(
"zit commit: cannot commit '{s}': missing from the working tree (use 'zit rm' to stop tracking it)\n",
.{path},
) catch {};
return .failure;
}
/// A managed/selected file is present but not a regular file (B-commit-10).
/// A tracked SYMLINK is the wedge blocker B3 left in the field (a pre-fix `add
/// .` registered it, and every later commit refuses): 0.1.0 does not version
/// symlinks, so the only way forward is to stop tracking it — the message names
/// that recovery instead of leaving it to be guessed. A fifo/socket keeps the
/// bare refusal: no recovery is being recommended for content zit never claimed.
fn commitNotRegular(s: cli.Streams, path: []const u8, is_symlink: bool) cli.Exit {
if (is_symlink) {
s.err.print(
"zit commit: cannot commit '{s}': " ++ symlink_unversioned ++
"; run 'zit rm {s}' to stop tracking it\n",
.{ path, path },
) catch {};
return .failure;
}
s.err.print("zit commit: cannot commit '{s}': not a regular file\n", .{path}) catch {};
return .failure;
}
/// A managed/selected file could not be read or stored (B-commit-15). Raised
/// before `logstore.commit`, so nothing durable is written.
fn commitCaptureIo(s: cli.Streams, path: []const u8) cli.Exit {
s.err.print("zit commit: cannot commit '{s}': input/output error\n", .{path}) catch {};
return .failure;
}
/// A tracked file too large for one artifact frame. States the actual size and
/// the limit, because the operator's next question is "by how much" — and says
/// the limit is the repository format's, so it does not read as a setting they
/// should go looking for.
fn commitTooLarge(s: cli.Streams, path: []const u8, size: u64) cli.Exit {
s.err.print(
"zit commit: cannot commit '{s}': {d} bytes exceeds the {d}-byte limit on one file\n",
.{ path, size, main.max_tracked_file_bytes },
) catch {};
s.err.print("zit commit: that limit is the repository format's artifact size, not a setting\n", .{}) catch {};
return .failure;
}
/// A named path in a partial commit is not tracked (B-commit-11). Educating: a
/// file must be `zit add`ed before it can be committed.
fn commitNotTracked(s: cli.Streams, path: []const u8) cli.Exit {
s.err.print("zit commit: '{s}' is not tracked (use 'zit add' first)\n", .{path}) catch {};
return .failure;
}
pub fn runAmend(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
// 1. Parse (B-amend-1). Usage errors → exit 2; `--help` wins (B-amend-13).
const parsed = switch (cli.parseAmendArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "amend", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "amend", .extra_argument, tok),
.repeated_option => |tok| return cli.usageError(streams, "amend", .repeated_option, tok),
.empty_value => |tok| return cli.usageError(streams, "amend", .empty_value, tok),
.bad_date => |tok| return cli.usageError(streams, "amend", .bad_date, tok),
.no_override => return cli.usageError(streams, "amend", .no_override, ""),
};
if (parsed.help) {
streams.out.writeAll(amend_help) catch {};
return .ok;
}
// NAME is required (B-amend-14): a missing positional is a usage error, before
// any repository access.
const name = parsed.name orelse return cli.usageError(streams, "amend", .no_name, "");
// 2. Open the repository read-only for resolution (B-amend-15: the repository
// check precedes resolution — `openRepo` renders not-a-zit-repository and
// exits 1). Full derive: `resolve`/`tagsOf` read the tag tables.
var repo: Repository = undefined;
var root_dir: std.Io.Dir = undefined;
const rc = openRepo(gpa, io, arena, streams, "amend", amb.repo_override, false, false, &repo, &root_dir);
if (rc != .ok) return rc;
defer root_dir.close(io);
// 3. Resolve NAME to a check-in and capture everything the mint needs — the
// target's 64-hex hash and, for `--branch`, the parent syms to cancel
// (repo-state §6.2) — all duped onto `arena` so they outlive the read view,
// which is closed before the rw append (commit's durability pattern).
var target_hash: []const u8 = undefined;
var parent_syms: []const []const u8 = &.{};
{
defer repo.deinit(gpa);
const rid = repo.resolve(name, .checkin) catch |e| switch (e) {
// B-amend-9: an ambiguous hash prefix (repo-state §8.3).
error.Ambiguous => {
streams.err.print("zit amend: name '{s}' is ambiguous\n", .{name}) catch {};
return .failure;
},
error.OutOfMemory => return stateOom(streams, "amend"),
error.NotFound => {
// B-amend-10: a NAME that names a stored non-check-in artifact (a
// control artifact, say) is refused BY its actual type — T cards
// target check-ins. Anything else is the B-amend-9 not-found.
if (amendArtifactType(gpa, &repo, name)) |type_name| {
streams.err.print("zit amend: '{s}' is a {s}, not a check-in\n", .{ name, type_name }) catch {};
} else {
streams.err.print("zit amend: check-in '{s}' not found\n", .{name}) catch {};
}
return .failure;
},
};
target_hash = arena.dupe(u8, repo.hashOf(rid)) catch return amendOom(streams);
// The amendment targets a resolved check-in by its full §3.6 artifact id.
std.debug.assert(artifact.validateName(target_hash));
// repo-state §6.2: a `--branch` move cancels the target's inherited
// (propagating) sym-* tags, minus the branch's own re-established sym. The
// surfaced names are duped onto `arena` (the view's interned names die at
// `repo.deinit`).
if (parsed.branch) |bname| {
const want_sym = std.mem.concat(arena, u8, &.{ sym_prefix, bname }) catch return amendOom(streams);
const entries = repo.state().tagsOf(gpa, rid) catch return amendOom(streams);
defer gpa.free(entries);
parent_syms = selectInheritedSyms(arena, entries, want_sym) catch return amendOom(streams);
}
}
// 4. Compose the one control artifact's T run (B-amend-1/4), each card
// retargeted at the check-in (artifact §5.4). At least one override is
// present (the parser's `no_override` guard), so the run is non-empty.
const tags = cli.composeAmendTags(arena, target_hash, .{
.comment = parsed.comment,
.user = parsed.user,
.date = parsed.date,
.branch = parsed.branch,
.parent_syms = parent_syms,
}) catch return amendOom(streams);
// 5-6. Assemble, serialize, and durably store the one control artifact.
var cname_buf: [64]u8 = undefined;
const control_name = mintControlArtifact(gpa, io, root_dir, amb, tags, &cname_buf) catch |e|
return amendLogFail(streams, e);
// 7. Success (B-amend-1/16): the one confirmation line to stdout, the full
// control-artifact hash as a fixed-position token (mirrors commit's
// verb-prefixed line). Every diagnostic already went to stderr.
streams.out.print("amend {s}\n", .{control_name}) catch {};
return .ok;
}
/// Renders `zit amend: out of memory` and returns `.failure`.
fn amendOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit amend: out of memory\n") catch {};
return .failure;
}
/// Renders a B-amend log open/put/commit failure. Exhaustive over the widest
/// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError`
/// coerce into it, so one mapper serves all three.
fn amendLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit amend: out of memory\n") catch {},
error.Storage => s.err.writeAll("zit amend: cannot write the amendment: input/output error\n") catch {},
error.Corrupt => s.err.writeAll("zit amend: the repository is corrupt\n") catch {},
}
return .failure;
}
/// The external type name of the stored artifact named `name` when it is NOT a
/// check-in (B-amend-10), or null when `name` names no stored artifact, is
/// unparseable, or IS a check-in manifest (in which case the not-found diagnostic
/// stands — `resolve` already ruled it out as a crosslinked check-in). Reads the
/// raw artifact through the repo's content channel and classifies it; the
/// returned name is a static string, valid after the parse is freed.
fn amendArtifactType(gpa: std.mem.Allocator, repo: *Repository, name: []const u8) ?[]const u8 {
const bytes = (repo.blob(gpa, name) catch return null) orelse return null;
defer gpa.free(bytes);
var m = artifact.parse(gpa, bytes) catch return null;
defer m.deinit();
if (m.kind == .manifest) return null; // a manifest IS a check-in; let not-found stand
return m.kind.externalName();
}
/// Captures a mapper's stderr into a fixed buffer and returns (exit, bytes).
fn commitMapCapture(comptime run_map: anytype, args: anytype) struct { code: cli.Exit, err: []const u8 } {
const S = struct {
var err_buf: [256]u8 = undefined;
};
var w = Io.Writer.fixed(&S.err_buf);
var sink: [1]u8 = undefined;
var wnull = Io.Writer.fixed(&sink);
const s: cli.Streams = .{ .out = &wnull, .err = &w };
const code = @call(.auto, run_map, .{s} ++ args);
return .{ .code = code, .err = w.buffered() };
}
// Same guard as the `zit update` mapper: `Corrupt` alleges damaged storage,
// `StateRecordInvalid` does not. `zit merge` maps most read-path errors onto
// one corruption line, so these two have to be pinned apart explicitly or they
// drift back into that group.
test "merge: the two read-path failures do not share a diagnostic" {
// `commitMapCapture` renders into one static buffer, so each message is
// copied out before the next render overwrites it.
var a: [256]u8 = undefined;
var b: [256]u8 = undefined;
const corrupt = mergeFailText(&a, error.Corrupt);
const state = mergeFailText(&b, error.StateRecordInvalid);
try std.testing.expect(!std.mem.eql(u8, corrupt, state));
try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null);
try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null);
}
/// Render one `ApplyError` through `mergeApplyFail` into `buf`.
fn mergeFailText(buf: []u8, err: update.ApplyError) []const u8 {
const rendered = commitMapCapture(mergeApplyFail, .{err}).err;
// `buf` is the caller's, `rendered` borrows `commitMapCapture`'s static
// buffer: the fit is a contract of this helper, not a coincidence of the
// two sizes agreeing today.
std.debug.assert(rendered.len <= buf.len);
@memcpy(buf[0..rendered.len], rendered);
return buf[0..rendered.len];
}
/// Render one `ApplyError` through `bisectMoveFail` into `buf`.
fn bisectFailText(buf: []u8, err: update.ApplyError) []const u8 {
const rendered = commitMapCapture(bisectMoveFail, .{err}).err;
std.debug.assert(rendered.len <= buf.len);
@memcpy(buf[0..rendered.len], rendered);
return buf[0..rendered.len];
}
// `zit bisect` moves the working tree through the SAME `update.applyCheckin`
// engine as update, open and merge, so it reaches `StateRecordInvalid` on the
// same intact-clone artifacts they do. It is the fourth mapper of that one
// error set and must make the same distinction they make.
test "bisect: the two read-path failures do not share a diagnostic" {
var a: [256]u8 = undefined;
var b: [256]u8 = undefined;
const corrupt = bisectFailText(&a, error.Corrupt);
const state = bisectFailText(&b, error.StateRecordInvalid);
try std.testing.expect(!std.mem.eql(u8, corrupt, state));
try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null);
try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null);
}
test "commit B-commit-15: checkout-read errors map to the contract messages, exit 1" {
const Case = struct { err: checkout.CheckoutError, want: []const u8 };
const cases = [_]Case{
.{ .err = error.Corrupt, .want = "zit commit: cannot read checkout state: .zit-checkout is corrupt\n" },
.{ .err = error.Storage, .want = "zit commit: cannot read checkout state: input/output error\n" },
};
for (cases) |c| {
const r = commitMapCapture(checkoutFail, .{ "commit", c.err });
try std.testing.expectEqual(cli.Exit.failure, r.code);
try std.testing.expectEqualStrings(c.want, r.err);
}
}
test "commit B-commit-15: managed-read errors map to the contract messages, exit 1" {
const Case = struct { err: managed.ManagedError, want: []const u8 };
const cases = [_]Case{
.{ .err = error.Corrupt, .want = "zit commit: cannot read tracked files: .zit-checkout is corrupt\n" },
.{ .err = error.Storage, .want = "zit commit: cannot read tracked files: input/output error\n" },
.{ .err = error.TooLong, .want = "zit commit: cannot read tracked files: a tracked path is too long\n" },
.{ .err = error.TooMany, .want = "zit commit: cannot read tracked files: too many tracked files\n" },
.{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
};
for (cases) |c| {
const r = commitMapCapture(managedFail, .{ "commit", c.err });
try std.testing.expectEqual(cli.Exit.failure, r.code);
try std.testing.expectEqualStrings(c.want, r.err);
}
}
test "commit B-commit-15: log open/put/commit errors map to the contract messages, exit 1" {
const Case = struct { err: logstore.OpenError, want: []const u8 };
const cases = [_]Case{
.{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
.{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" },
.{ .err = error.Corrupt, .want = "zit commit: the repository is corrupt\n" },
};
for (cases) |c| {
const r = commitMapCapture(commitLogFail, .{c.err});
try std.testing.expectEqual(cli.Exit.failure, r.code);
try std.testing.expectEqualStrings(c.want, r.err);
}
}
test "commit B-commit-15: parent-content errors map to the contract messages, exit 1" {
const Case = struct { err: logstore.ContentError, want: []const u8 };
const cases = [_]Case{
.{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" },
.{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" },
.{ .err = error.Corrupt, .want = "zit commit: the parent check-in is corrupt\n" },
};
for (cases) |c| {
const r = commitMapCapture(commitContentFail, .{c.err});
try std.testing.expectEqual(cli.Exit.failure, r.code);
try std.testing.expectEqualStrings(c.want, r.err);
}
}
test "commit B-commit-10/-11/-15: the single-message diagnostics render exactly, exit 1" {
try std.testing.expectEqualStrings(
"zit commit: cannot read checkout state: .zit-checkout is missing\n",
commitMapCapture(commitCheckoutMissing, .{}).err,
);
try std.testing.expectEqualStrings(
"zit commit: the parent check-in is corrupt (not present in this repository)\n",
commitMapCapture(commitParentCorrupt, .{"not present in this repository"}).err,
);
try std.testing.expectEqualStrings(
"zit commit: the check-in was recorded but the working state could not be updated: input/output error\n",
commitMapCapture(commitCheckoutWriteFail, .{}).err,
);
try std.testing.expectEqualStrings("zit commit: out of memory\n", commitMapCapture(commitOom, .{}).err);
try std.testing.expectEqualStrings(
"zit commit: cannot commit 'a.txt': missing from the working tree (use 'zit rm' to stop tracking it)\n",
commitMapCapture(commitMissing, .{"a.txt"}).err,
);
try std.testing.expectEqualStrings(
"zit commit: cannot commit 'link': not a regular file\n",
commitMapCapture(commitNotRegular, .{ "link", false }).err,
);
// F-B3: a tracked symlink is the one non-regular kind with a stated
// recovery, because a pre-fix `add .` is how it got tracked at all.
try std.testing.expectEqualStrings(
"zit commit: cannot commit 'link': symlink (not versioned in 0.1.0); run 'zit rm link' to stop tracking it\n",
commitMapCapture(commitNotRegular, .{ "link", true }).err,
);
try std.testing.expectEqualStrings(
"zit commit: cannot commit 'x': input/output error\n",
commitMapCapture(commitCaptureIo, .{"x"}).err,
);
try std.testing.expectEqualStrings(
"zit commit: 'new.txt' is not tracked (use 'zit add' first)\n",
commitMapCapture(commitNotTracked, .{"new.txt"}).err,
);
}
// ---------------------------------------------------------------------------
// `zit submanifest [show|enable|disable|retune|analyze]` — the sub-manifest
// operator surface (draft-fossil-submanifests-00 §6.3, §6.4, §8.1).
//
// Four actions over two settings. `show` reports them; `enable`/`disable` move
// §8.1's emission gate, `enable` pinning §6.3's partition parameter the first
// time and printing the consequence warning; `retune` performs §6.4's manual,
// forward-only re-pin; `analyze` runs §6.3's selection pass and writes nothing.
//
// §8.2's provisional-phase rules make enabling an act with real consequences,
// so it is opt-in with the warning printed, not a silent toggle and not an
// indefinite refusal: the maintainer's compatibility contract says zit 0.2.0
// stays backwards compatible with fossil, and it does — the DEFAULT is off, and
// §7.2's transfer gate refuses a pre-2.29 peer every S-bearing artifact rather
// than hand it something it cannot read.
// ---------------------------------------------------------------------------
pub fn runSubmanifest(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
const parsed = switch (cli.parseSubmanifestArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "submanifest", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "submanifest", .extra_argument, tok),
.bad_k => |tok| {
streams.err.print(
"zit submanifest: --k wants an integer {d}..{d} (got '{s}')\n",
.{ artifact.min_k, artifact.max_k, tok },
) catch {};
streams.err.writeAll("try 'zit submanifest --help'\n") catch {};
return .usage;
},
.missing_value => |tok| {
streams.err.print("zit submanifest: {s} wants a path\n", .{tok}) catch {};
streams.err.writeAll("try 'zit submanifest --help'\n") catch {};
return .usage;
},
};
if (parsed.help) {
streams.out.writeAll(main.submanifest_help) catch {};
return .ok;
}
const root = (cli.discoverRoot(arena, io) catch return submanifestOom(streams)) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
return switch (parsed.action) {
.show => submanifestShow(gpa, io, root_dir, streams),
.enable => submanifestEnable(gpa, io, root_dir, parsed.k, streams),
.disable => submanifestDisable(gpa, io, root_dir, streams),
.retune => submanifestRetune(gpa, io, root_dir, parsed.k, streams),
.analyze => submanifestAnalyze(gpa, io, root_dir, parsed.names_path, streams),
};
}
/// Read the repository's config frame. A missing or unreadable frame is a
/// repository-level failure here (unlike on the commit path, where the safe
/// default is simply "off"): every action below either reports the settings or
/// rewrites them, and both would be lies over a config that did not load.
fn submanifestConfig(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
) ?logstore.OwnedConfig {
var file = root_dir.openFile(io, repo_filename, .{}) catch {
streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
return null;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
return null;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
return null;
};
defer log.deinit(gpa);
const oc = log.readConfig(gpa) catch {
streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
return null;
};
if (oc == null) {
streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {};
return null;
}
return oc;
}
/// An outer null preserves the cached value; an outer non-null writes its inner
/// value, including null to conservatively clear an unreadable checkout's cache.
/// This is implementation-only state, not an RFC setting.
fn replaceSettledCostShape(shape: ?[32]u8) ??[32]u8 {
return shape;
}
/// Read-modify-write of the sub-manifest settings, preserving every unrelated
/// config field (plan E last-frame-wins). `settled_cost_shape` is tri-state so only
/// disable may preserve its cache; enable and retune replace it even with null.
fn submanifestWrite(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
enabled: ?bool,
k: ?u8,
settled_cost_shape: ??[32]u8,
streams: cli.Streams,
) bool {
var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch {
streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
return false;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
return false;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
return false;
};
defer log.deinit(gpa);
var oc = (log.readConfig(gpa) catch null) orelse {
streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {};
return false;
};
defer oc.deinit(gpa);
var next = oc.config;
if (enabled) |value| next.enable_submanifests = value;
if (k) |value| next.submanifest_k = value;
if (settled_cost_shape) |shape| next.submanifest_settled_cost_shape = shape;
log.putConfig(next) catch {
streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
return false;
};
log.commit() catch {
streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {};
return false;
};
return true;
}
fn submanifestShow(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
defer oc.deinit(gpa);
const s: zit.submanifest.Settings = .of(oc.config);
streams.out.print("enable-submanifests: {s}\n", .{if (s.enabled) "on" else "off"}) catch {};
if (s.pinned()) {
streams.out.print("submanifest-k: {d}\n", .{s.k}) catch {};
} else {
streams.out.writeAll("submanifest-k: (not pinned)\n") catch {};
}
// §6.4's `submanifest-auto-retune` — shown because it is now a PROPAGATED
// setting: a clone adopts it from its origin over sync §9.3, so an operator
// can arrive at a repository that re-tunes on commit without ever having
// set it here. A setting that can reach a repository without the operator's
// act has to be visible to the operator.
streams.out.print(
"submanifest-auto-retune: {s}\n",
.{if (oc.config.submanifest_auto_retune) "on" else "off"},
) catch {};
// §8.1's local indicator, the thing that arms the §7.2 transfer gate even
// after the setting is turned back off. Worth showing: it is the durable
// half of "this repository is a disposable test fixture".
streams.out.print(
"seen-submanifest: {s}\n",
.{if (oc.config.seen_submanifest) "yes (this repository holds S-bearing artifacts)" else "no"},
) catch {};
switch (zit.submanifest.emission(s)) {
.off => streams.out.writeAll("commits emit: flat manifests\n") catch {},
.out_of_range => |v| streams.out.print(
"commits emit: flat manifests — {s} ({d}; must be {d}..{d})\n",
.{ zit.submanifest.out_of_range_report_prefix, v, artifact.min_k, artifact.max_k },
) catch {},
.tree => |v| streams.out.print("commits emit: tree manifests, partitioned at k={d}\n", .{v}) catch {},
}
// §6.3's project-scope requirement is not met; say so where an operator will
// read it rather than only in a source comment.
streams.out.writeAll(submanifest_scope_gap) catch {};
return .ok;
}
/// §6.3 — "Project scope (configuration propagating to clones) is REQUIRED".
/// A clone now adopts these settings over [FossilSync] §9.3's scalar `/config`
/// channel, so the shortfall is narrower than it was: what remains is that a
/// repository already in existence never re-reads them from a peer. Printed by
/// `show` and by `enable`, and kept honest about which half works.
const submanifest_scope_gap =
"\nscope: a CLONE adopts these settings from its origin (§6.3 project scope, over\n" ++
"the sync §9.3 configuration exchange), so a fresh clone partitions the way this\n" ++
"repository does. An EXISTING repository is not refreshed by a later pull or\n" ++
"sync — change it here and on each peer. Repositories pinned differently still\n" ++
"exchange artifacts correctly (§6.4) — they just stop sharing sub-manifests.\n";
/// The cache key is an effective F-set shape, never a checkout filename list.
/// Failure to read the current check-in leaves the setting change valid but
/// uncached; the following commit then conservatively scans.
fn submanifestSettledShape(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
) ?[32]u8 {
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var repo: Repository = undefined;
repo.open(gpa, io, root_dir, .{}) catch return null;
defer repo.deinit(gpa);
const rid = repo.currentCheckoutRid() orelse return null;
var files = (repo.files(gpa, rid) catch return null) orelse return null;
defer files.deinit(gpa);
var entries: std.ArrayList(artifact.FCard) = .empty;
defer entries.deinit(arena);
var it = files.iterator();
while (it.next()) |entry| entries.append(arena, entry) catch return null;
var digest: [32]u8 = undefined;
_ = zit.submanifest.costShapeDigest(entries.items, &digest);
return digest;
}
fn submanifestEnable(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
k_arg: ?u8,
streams: cli.Streams,
) cli.Exit {
var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
defer oc.deinit(gpa);
const s: zit.submanifest.Settings = .of(oc.config);
// §6.3: "The partition parameter k is selected once, when the
// enable-submanifests setting is first turned on for a repository, and
// pinned". A repository that already has one keeps it — re-pinning is a
// re-tune (§6.4), which is a different verb with a different consequence.
if (s.pinned() and k_arg != null and k_arg.? != s.k) {
streams.err.print(
"zit submanifest: k is already pinned to {d}; changing it is a re-tune — " ++
"use 'zit submanifest retune --k {d}'\n",
.{ s.k, k_arg.? },
) catch {};
return .failure;
}
// §6.3: "The partition parameter k is selected once, when the
// enable-submanifests setting ... is first turned on" — and the draft names
// the objective it is selected BY, so selecting it is the writer's job. An
// explicit --k overrides; an already-pinned repository keeps its value
// (re-pinning is a re-tune); otherwise the analysis pass answers, and
// §8.1's "MUST NOT enable emission ... with no pinned parameter" is
// satisfied by pinning rather than by refusing.
var analyzed_over: ?usize = null;
const k: u8 = if (k_arg) |v| v else if (s.pinned()) s.k else blk: {
const sel = submanifestSelectK(gpa, io, root_dir, streams) orelse return .failure;
analyzed_over = sel.names;
// #41: history outranks the snapshot when both can speak. The
// snapshot objective is theory over ONE file set; the replay is the
// same choice measured against this repository's own recent commits
// (argmin of realized steady-state materialization). No verdict, no
// gate — the one decision that stays with the operator is the
// compatibility spend, stated below as it always was.
break :blk submanifestReplayK(gpa, io, root_dir, sel.k, streams) orelse sel.k;
};
// §6.3: "MUST support every value from 1 to 12 inclusive (the pinned value
// MUST lie in that range)". Refuse to PIN an out-of-range value; the
// fallback-and-report rule exists for a value that reaches a writer some
// other way, not as a licence to store one here.
if (!zit.submanifest.inRange(k)) {
streams.err.print(
"zit submanifest: k must be {d}..{d} (got {d})\n",
.{ artifact.min_k, artifact.max_k, k },
) catch {};
return .failure;
}
const settled_shape = submanifestSettledShape(gpa, io, root_dir);
if (!submanifestWrite(
gpa,
io,
root_dir,
true,
k,
replaceSettledCostShape(settled_shape),
streams,
)) return .failure;
// §8.1/§8.2 — the consequence, stated before the operator's next commit,
// not buried in documentation.
streams.out.print("enable-submanifests: on (submanifest-k pinned to {d})\n", .{k}) catch {};
if (analyzed_over) |names| {
// §6.3: "The selection is advisory rather than canonical -- any pinned
// k yields conforming artifacts". Say what it was measured over, so the
// operator can tell a selection from a default.
streams.out.print(
"submanifest-k was selected by the §6.3 analysis over this check-in's {d} names;\n" ++
"'zit submanifest analyze' shows the full table, 'retune --k N' moves the pin.\n",
.{names},
) catch {};
}
streams.out.print("\nWARNING: {s}\n", .{zit.sync.submanifest_enable_warning}) catch {};
streams.out.writeAll(submanifest_scope_gap) catch {};
return .ok;
}
fn submanifestDisable(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
) cli.Exit {
// §8.1: "Disabling the setting stops the emission of new tree manifests; it
// cannot retire existing ones (artifact names are immutable)." The pin is
// deliberately left in place — it is the repository's identity for content
// sharing, and clearing it would silently make a later re-enable a re-tune.
if (!submanifestWrite(gpa, io, root_dir, false, null, null, streams)) return .failure;
streams.out.writeAll("enable-submanifests: off\n") catch {};
var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .ok;
defer oc.deinit(gpa);
if (oc.config.seen_submanifest) {
streams.out.writeAll(
"note: this repository already holds S-bearing artifacts. Disabling stops new\n" ++
"tree manifests; it cannot retire the existing ones, and peers below Fossil\n" ++
"2.29 are still refused every one of them.\n",
) catch {};
}
return .ok;
}
fn submanifestRetune(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
k_arg: ?u8,
streams: cli.Streams,
) cli.Exit {
// §6.4: "A re-tune MUST be manual and operator-initiated". The value is
// never inferred here — no argument is a usage failure, not a call into the
// analysis pass.
const k = k_arg orelse {
streams.err.writeAll(
"zit submanifest: retune needs an explicit value: 'zit submanifest retune --k N'.\n" ++
"A re-tune is always manual; run 'zit submanifest analyze' first if you want the " ++
"analyzed optimum.\n",
) catch {};
return .failure;
};
if (!zit.submanifest.inRange(k)) {
streams.err.print(
"zit submanifest: k must be {d}..{d} (got {d})\n",
.{ artifact.min_k, artifact.max_k, k },
) catch {};
return .failure;
}
var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure;
defer oc.deinit(gpa);
const s: zit.submanifest.Settings = .of(oc.config);
if (s.k == k) {
streams.out.print("submanifest-k: already {d} — nothing to do\n", .{k}) catch {};
return .ok;
}
const settled_shape = submanifestSettledShape(gpa, io, root_dir);
if (!submanifestWrite(
gpa,
io,
root_dir,
null,
k,
replaceSettledCostShape(settled_shape),
streams,
)) return .failure;
streams.out.print("submanifest-k: {d} -> {d}\n", .{ s.k, k }) catch {};
// §6.4: "The first commit after it shares no sub-manifest with its parent's
// tree and rewrites every leaf, once ... the every-leaf rewrite lands as a
// surprisingly large commit at an arbitrary moment." Said now, so the
// moment is not arbitrary.
if (s.enabled) {
streams.out.writeAll(
"note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++
"leaf, once. No history is rewritten and no reader behaves differently.\n",
) catch {};
}
return .ok;
}
/// §6.3's objective at every k in `min_k..max_k`, in order, into `out`; returns
/// how many rows were filled. The shared core of `analyze` (which prints the
/// whole table) and `enable` (which pins only the winner), so the value the
/// operator is shown is by construction the value that gets pinned.
fn submanifestCosts(
gpa: std.mem.Allocator,
entries: []const artifact.FCard,
out: *[artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost,
) std.mem.Allocator.Error!usize {
var n: usize = 0;
var k: u4 = artifact.min_k;
while (true) : (k += 1) {
out[n] = try zit.submanifest.cost(gpa, entries, k, .sha3_256);
n += 1;
// Explicit terminator: `k` is a u4, so raising `max_k` to 15 would make
// `k += 1` wrap to 0 and spin forever on a `k <= max_k` condition.
if (k == artifact.max_k) break;
}
return n;
}
/// §6.3's selection: the k minimizing the objective over the repository's
/// current name set. This is the writer's choice, not the operator's — the
/// draft has the parameter "selected once, when the enable-submanifests setting
/// is first turned on", and names the objective it is selected by. Returns null
/// after reporting why no value could be selected; the caller then refuses to
/// enable rather than substituting one.
fn submanifestSelectK(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
) ?struct { k: u8, names: usize } {
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const entries = submanifestNamesFromCheckout(gpa, arena_state.allocator(), io, root_dir, streams) orelse {
streams.err.writeAll(
"zit submanifest: no file set to select a partition parameter from — " ++
"pin one explicitly with 'zit submanifest enable --k N'.\n",
) catch {};
return null;
};
if (entries.len == 0) {
streams.err.writeAll(
"zit submanifest: the current check-in holds no files, so there is nothing to\n" ++
"select a partition parameter from — pin one with 'zit submanifest enable --k N'.\n",
) catch {};
return null;
}
var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined;
const n = submanifestCosts(gpa, entries, &costs) catch {
_ = submanifestOom(streams);
return null;
};
const winner = zit.submanifest.best(costs[0..n]) orelse {
// Every k leaves this name set flat (§6.2 step 2), so the objective has
// no candidate to minimize and there is no analyzed value to pin. Not a
// reason to invent one: the set will not stay this small, and §6.3
// leaves the choice to the operator when the pass cannot make it.
streams.err.print(
"zit submanifest: no k partitions this repository's {d} names into a tree " ++
"(§6.2 step 2),\nso no parameter can be selected — pin one with " ++
"'zit submanifest enable --k N' if you want one anyway.\n",
.{entries.len},
) catch {};
return null;
};
return .{ .k = winner.k, .names = entries.len };
}
/// The §6.3 analysis pass over a name set: k = 1..12, the objective for each,
/// and the §6.5 prediction beside the measured optimum.
///
/// Writes nothing. §6.4: "an implementation MAY advise of drift from the
/// analyzed optimum but MUST NOT re-tune automatically" — so this reports and
/// stops, and moving the pin is the operator's separate `retune` act.
fn submanifestAnalyze(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
names_path: ?[]const u8,
streams: cli.Streams,
) cli.Exit {
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
const aa = arena_state.allocator();
const entries = (if (names_path) |p|
submanifestNamesFromFile(aa, io, p, streams)
else
submanifestNamesFromCheckout(gpa, aa, io, root_dir, streams)) orelse {
if (names_path == null) {
streams.err.writeAll("zit submanifest: analyze a name list instead with '--names PATH'\n") catch {};
}
return .failure;
};
if (entries.len == 0) {
streams.err.writeAll("zit submanifest: the name set is empty — nothing to analyze\n") catch {};
return .failure;
}
streams.out.print("names: {d}\n", .{entries.len}) catch {};
streams.out.writeAll("\n k leaves depth S-bytes wmean-leaf total flat-F-bytes\n") catch {};
var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined;
const n = submanifestCosts(gpa, entries, &costs) catch return submanifestOom(streams);
for (costs[0..n]) |c| {
if (c.flat) {
streams.out.print(
"{d:>3} {s:>44} {d:>12}\n",
.{ c.k, "(flat: fewer than two groups, §6.2 step 2)", c.flat_bytes },
) catch {};
} else {
streams.out.print(
"{d:>3} {d:>6} {d:>5} {d:>8} {d:>10.1} {d:>8.1} {d:>12}\n",
.{ c.k, c.leaves, c.heights, c.s_bytes, c.weighted_mean_leaf_bytes, c.total, c.flat_bytes },
) catch {};
}
}
// #41, stated before any early return: a name list carries no history,
// and history is the replay's whole subject.
if (names_path != null) {
streams.out.writeAll(
"\nreplay: unavailable with --names — a name list carries no commit history,\n" ++
"and history is what decides whether enabling pays (see 'zit submanifest --help').\n",
) catch {};
}
const winner = zit.submanifest.best(costs[0..n]) orelse {
streams.out.writeAll(
"\nno k partitions this name set into a tree (§6.2 step 2) — commits stay flat at every k.\n",
) catch {};
return .ok;
};
streams.out.print(
"\nanalyzed optimum: k={d} (objective {d:.1})\n",
.{ winner.k, winner.total },
) catch {};
// §6.5, informative: "the analyzed optimum tracks roughly half the base-2
// logarithm of the file count". Printed beside the measurement so drift
// between the two is visible rather than assumed away.
streams.out.print(
"§6.5 prediction: ~{d:.1} (half the base-2 log of {d} names)\n",
.{ zit.submanifest.predictedK(entries.len), entries.len },
) catch {};
// #41: the replay — the static table above is theory over ONE snapshot;
// whether enabling would have PAID on this repository is a property of
// its history (churn/reuse), so it is measured by replaying that history
// through the real builder and the real delta encoder.
if (names_path == null) {
submanifestReplaySection(gpa, io, root_dir, winner.k, streams);
}
streams.out.writeAll(
"\nAdvisory only — any pinned k yields conforming artifacts (§6.3). Nothing was\n" ++
"changed; move the pin with 'zit submanifest retune --k N' (§6.4, manual only).\n",
) catch {};
return .ok;
}
/// The #41 replay window: enough steady-state commits for churn structure to
/// show, small enough that analyze stays an offline-audit-scale wait.
const replay_window: usize = 200;
/// Render the replay for the static winner and its in-range neighbours. Any
/// failure is reported and skipped — the replay is evidence for a decision,
/// never a gate on printing the static analysis above it.
fn submanifestReplaySection(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
winner_k: u4,
streams: cli.Streams,
) void {
var ks_buf: [3]u4 = undefined;
var n_ks: usize = 0;
if (winner_k > artifact.min_k) {
ks_buf[n_ks] = winner_k - 1;
n_ks += 1;
}
ks_buf[n_ks] = winner_k;
n_ks += 1;
if (winner_k < artifact.max_k) {
ks_buf[n_ks] = winner_k + 1;
n_ks += 1;
}
var repo: Repository = undefined;
Repository.open(&repo, gpa, io, root_dir, .{}) catch {
streams.out.writeAll("\nreplay: cannot open the repository — skipped.\n") catch {};
return;
};
defer repo.deinit(gpa);
var rep = zit.submanifest.replay(gpa, &repo, replay_window, ks_buf[0..n_ks]) catch {
streams.out.writeAll("\nreplay: failed reading history — skipped.\n") catch {};
return;
};
defer rep.deinit(gpa);
renderReplay(&rep, streams);
}
/// The replay table: flat actuals first, then each arm's steady-state deltas
/// on BOTH axes with bootstrap and breakeven separate — the verdict gates on
/// full-form/materialization (where the F-card wall actually breaks people),
/// with store inflation reported as the price.
fn renderReplay(rep: *const zit.submanifest.Replay, streams: cli.Streams) void {
if (rep.commits < 2) {
streams.out.print(
"\nreplay: only {d} replayable commit(s) — too little history to measure; not evidence either way.\n",
.{rep.commits},
) catch {};
return;
}
streams.out.print(
"\nreplayed over the last {d} commits (this repository's own history):\n" ++
"flat (actual): store {d} bytes, full-form {d} bytes\n",
.{ rep.commits, rep.flat_store, rep.flat_full },
) catch {};
const steady_commits: u64 = @intCast(rep.commits - 1);
const flat_first_store = rep.flat_store - rep.flat_store_steady;
for (rep.arms) |arm| {
if (arm.all_flat) {
streams.out.print("k={d:<2} every replayed commit built flat (§6.2 step 2) — no evidence at this k\n", .{arm.k}) catch {};
continue;
}
const full_pct = signedPct(arm.steady_full, rep.flat_full_steady);
const store_pct = signedPct(arm.steady_store, rep.flat_store_steady);
streams.out.print(
"k={d:<2} steady state: full-form {d:>6.1}%, store {d:>6.1}%; bootstrap {d} bytes",
.{ arm.k, full_pct, store_pct, arm.bootstrap_store },
) catch {};
// Breakeven on the STORE axis: commits until the bootstrap's extra
// stored bytes are repaid by the steady-state per-commit saving.
if (arm.bootstrap_store > flat_first_store and rep.flat_store_steady > arm.steady_store) {
const extra = arm.bootstrap_store - flat_first_store;
const saving_per_commit = (rep.flat_store_steady - arm.steady_store) / steady_commits;
if (saving_per_commit > 0) {
streams.out.print(", breakeven ~{d} commits", .{extra / saving_per_commit + 1}) catch {};
}
} else if (arm.steady_store >= rep.flat_store_steady) {
streams.out.writeAll(", breakeven: never on store bytes") catch {};
}
streams.out.writeAll("\n") catch {};
}
if (rep.bestArm()) |w| {
if (w.steady_full < rep.flat_full_steady) {
const full_pct = signedPct(w.steady_full, rep.flat_full_steady);
streams.out.print(
"verdict: k={d} — steady-state full-form {d:.1}% vs flat over this window.\n",
.{ w.k, full_pct },
) catch {};
if (rep.insideDesignEnvelope()) {
streams.out.print(
"note: flat materialization averages {d} KB/commit — inside the format's design\n" ++
"envelope, so the absolute saving is small; the real decision is the permanent\n" ++
"compatibility spend, and that one is yours.\n",
.{rep.flatFullSteadyPerCommit() / 1024},
) catch {};
}
} else {
streams.out.writeAll(
"verdict: no replayed k beat the flat arm's steady-state full-form cost over\n" ++
"this window — this churn pattern favors flat manifests.\n",
) catch {};
}
}
}
/// The #41 k-selector for `enable`: replay the snapshot winner and its
/// in-range neighbours over recent history and return the argmin of realized
/// steady-state materialization — the k this repository's own churn actually
/// rewards — or null when the replay cannot speak (no history, every arm
/// flat, unopenable repository), leaving the snapshot winner standing. Prints
/// which evidence chose the pin. Never refuses anything: choosing k is this
/// function's whole authority.
fn submanifestReplayK(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
snapshot_k: u8,
streams: cli.Streams,
) ?u8 {
if (!zit.submanifest.inRange(snapshot_k)) return null;
const winner_k: u4 = @intCast(snapshot_k);
var ks_buf: [3]u4 = undefined;
var n_ks: usize = 0;
if (winner_k > artifact.min_k) {
ks_buf[n_ks] = winner_k - 1;
n_ks += 1;
}
ks_buf[n_ks] = winner_k;
n_ks += 1;
if (winner_k < artifact.max_k) {
ks_buf[n_ks] = winner_k + 1;
n_ks += 1;
}
var repo: Repository = undefined;
Repository.open(&repo, gpa, io, root_dir, .{}) catch return null;
defer repo.deinit(gpa);
var rep = zit.submanifest.replay(gpa, &repo, replay_window, ks_buf[0..n_ks]) catch return null;
defer rep.deinit(gpa);
if (rep.commits < 2) return null;
const w = rep.bestArm() orelse return null;
streams.out.print(
"k={d} by replaying {d} commits (steady-state materialization {d:.1}% vs flat; " ++
"snapshot optimum k={d}).\n",
.{ w.k, rep.commits, signedPct(w.steady_full, rep.flat_full_steady), snapshot_k },
) catch {};
return w.k;
}
/// Percentage delta of `got` against `base`, signed: -40.0 means 40% below.
fn signedPct(got: u64, base: u64) f64 {
std.debug.assert(base > 0);
const g: f64 = @floatFromInt(got);
const b: f64 = @floatFromInt(base);
return (g - b) * 100.0 / b;
}
/// The current checkout's effective file set as F cards, on `aa`.
fn submanifestNamesFromCheckout(
gpa: std.mem.Allocator,
aa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
streams: cli.Streams,
) ?[]const artifact.FCard {
var repo: Repository = undefined;
repo.open(gpa, io, root_dir, .{}) catch {
streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {};
return null;
};
defer repo.deinit(gpa);
// The fact only — each caller states its own remedy, since `analyze` can be
// pointed at a name file and `enable` cannot.
const rid = repo.currentCheckoutRid() orelse {
streams.err.writeAll(
"zit submanifest: no check-in is open, so there is no file set to work from\n",
) catch {};
return null;
};
var fl = (repo.files(gpa, rid) catch {
streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {};
return null;
}) orelse {
streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {};
return null;
};
defer fl.deinit(gpa);
var list: std.ArrayList(artifact.FCard) = .empty;
var it = fl.iterator();
while (it.next()) |f| {
list.append(aa, .{
.filename = aa.dupe(u8, f.filename) catch return null,
.hash = if (f.hash) |h| aa.dupe(u8, h) catch return null else null,
.perm = if (f.perm) |p| aa.dupe(u8, p) catch return null else null,
.old_name = null, // §6.2 partitions on the filename only
.mode = f.mode,
}) catch return null;
}
return list.items;
}
/// A newline-separated name list from `path`, as F cards on `aa`.
///
/// §6.1: "The predicate reads the filename only: content hash, permission, and
/// old-name never influence the partition." So a name list is a complete input
/// to the partition; only the BYTE counts need a hash, and those depend on its
/// length, not its value — every entry gets a distinct 64-hex placeholder, the
/// width the SHA3-256 policy this pass measures under emits. Blank lines are
/// skipped; the list must already be in canonical order (§4.5.3), which is what
/// a manifest's F run and `fossil ls` both produce.
fn submanifestNamesFromFile(
aa: std.mem.Allocator,
io: std.Io,
path: []const u8,
streams: cli.Streams,
) ?[]const artifact.FCard {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, aa, .limited(max_tracked_file_bytes)) catch {
streams.err.print("zit submanifest: cannot read '{s}'\n", .{path}) catch {};
return null;
};
var list: std.ArrayList(artifact.FCard) = .empty;
var it = std.mem.splitScalar(u8, bytes, '\n');
var seq: u64 = 0;
while (it.next()) |raw| {
const name = std.mem.trimEnd(u8, raw, "\r");
if (name.len == 0) continue;
seq += 1;
var digest: [32]u8 = undefined;
std.mem.writeInt(u64, digest[0..8], seq, .little);
@memset(digest[8..], 0);
const hex = aa.alloc(u8, 64) catch return null;
_ = std.fmt.bufPrint(hex, "{x}", .{&digest}) catch return null;
list.append(aa, .{
.filename = name,
.hash = hex,
.perm = null,
.old_name = null,
.mode = .regular,
}) catch return null;
}
const items = list.items;
// §6.2's precondition: strictly ascending canonical order. Reported rather
// than sorted — a caller whose list is out of order is measuring a different
// name set than it thinks it is.
for (items, 0..) |e, i| {
if (i != 0 and !std.mem.lessThan(u8, items[i - 1].filename, e.filename)) {
streams.err.print(
"zit submanifest: '{s}' is not in canonical order after '{s}' " ++
"(the name list must be sorted, with no duplicates)\n",
.{ e.filename, items[i - 1].filename },
) catch {};
return null;
}
}
return items;
}
fn submanifestOom(s: cli.Streams) cli.Exit {
s.err.writeAll("zit submanifest: out of memory\n") catch {};
return .failure;
}
// ---------------------------------------------------------------------------
// zit checkpoint — the durable rebuild checkpoint's operator surface. Thin
// wiring only: parse, discover, open, call one zit.checkpoint entry point,
// print. No container parsing, tree building, or resume logic here.
// ---------------------------------------------------------------------------
pub fn runCheckpoint(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
const parsed = switch (cli.parseCheckpointArgs(verb_args)) {
.ok => |p| p,
.unknown_option => |tok| return cli.usageError(streams, "checkpoint", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "checkpoint", .extra_argument, tok),
};
if (parsed.help) {
streams.out.writeAll(main.checkpoint_help) catch {};
return .ok;
}
const root = (cli.discoverRoot(arena, io) catch return checkpointOom(streams)) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
return switch (parsed.action) {
.show => checkpointShow(gpa, io, root_dir, streams),
.build => checkpointBuild(gpa, io, root_dir, parsed.checkin, streams),
.verify => checkpointVerify(gpa, io, root_dir, streams),
.drop => blk: {
zit.checkpoint.drop(io, root_dir);
streams.out.writeAll("checkpoint: dropped\n") catch {};
break :blk .ok;
},
};
}
fn checkpointOom(streams: cli.Streams) cli.Exit {
streams.err.writeAll("zit checkpoint: out of memory\n") catch {};
return .failure;
}
fn checkpointShow(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams) cli.Exit {
var file = root_dir.openFile(io, repo_filename, .{}) catch {
streams.err.writeAll("zit checkpoint: cannot read repository: input/output error\n") catch {};
return .failure;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit checkpoint: cannot read repository: input/output error\n") catch {};
return .failure;
};
var fs = storage.FileStorage.init(io, file, st.size);
var loaded = zit.checkpoint.tryLoad(gpa, io, root_dir, fs.storageFile()) orelse {
streams.out.writeAll("checkpoint: none (or it no longer validates against this log)\n") catch {};
return .ok;
};
defer loaded.deinit(gpa);
streams.out.print(
"checkpoint: witness\n check-in: {s}\n prefix: {d} frames, {d} bytes\n",
.{ loaded.claim.checkin_hash, loaded.claim.prefix_key.frame_count, loaded.claim.prefix_key.end },
) catch {};
streams.out.writeAll(" validates against the live log\n") catch {};
return .ok;
}
fn checkpointBuild(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, checkin: ?[]const u8, streams: cli.Streams) cli.Exit {
var repo: zit.Repository = undefined;
// The ordinary open, caches and all: the derive cache's HIT ≡ recompute
// invariant makes a cache-served view equal to a paranoid derive, and
// `zit checkpoint verify` is the deliberately paranoid surface. Bypassing
// here made every build pay a full derive for nothing.
zit.Repository.open(&repo, gpa, io, root_dir, .{}) catch |e| {
streams.err.print("zit checkpoint: cannot read repository: {s}\n", .{openWhy(e)}) catch {};
return .failure;
};
defer repo.deinit(gpa);
const report = zit.checkpoint.build(gpa, io, &repo, root_dir, checkin) catch |e| {
streams.err.print("zit checkpoint: build failed: {s}\n", .{buildWhy(e)}) catch {};
return .failure;
};
streams.out.print(
"checkpoint: built at {s}\n files: {d} prefix frames: {d}\n",
.{ report.checkinHash(), report.files, report.prefix_frames },
) catch {};
return .ok;
}
fn checkpointVerify(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams) cli.Exit {
const verdict = zit.checkpoint.verify(gpa, io, root_dir) catch |e| {
streams.err.print("zit checkpoint: verify failed: {s}\n", .{buildWhy(e)}) catch {};
return .failure;
};
switch (verdict) {
.ok => |report| {
streams.out.print("checkpoint: verified ok ({d} files, {d} prefix frames)\n", .{ report.files, report.prefix_frames }) catch {};
return .ok;
},
.no_checkpoint => {
streams.out.writeAll("checkpoint: none to verify\n") catch {};
return .ok;
},
.stale => {
streams.err.writeAll("zit checkpoint: STALE — the sidecar no longer validates against this log; run 'zit checkpoint build' to replace it\n") catch {};
return .failure;
},
.derivation_mismatch => {
streams.err.writeAll("zit checkpoint: DERIVATION MISMATCH — the stored prefix record differs from an independent re-derivation; drop and rebuild\n") catch {};
return .failure;
},
.fileset_mismatch => {
streams.err.writeAll("zit checkpoint: FILE-SET MISMATCH — the fingerprint disagrees with the independently derived file set at the anchor check-in\n") catch {};
return .failure;
},
}
}
fn openWhy(e: anyerror) []const u8 {
return switch (e) {
error.NotARepo => "not a zit repository",
error.Corrupt => "repository is corrupt",
error.OutOfMemory => "out of memory",
else => "input/output error",
};
}
fn buildWhy(e: zit.checkpoint.BuildError) []const u8 {
return switch (e) {
error.OutOfMemory => "out of memory",
error.Storage => "input/output error",
error.Corrupt => "repository is corrupt",
error.CheckinNotFound => "check-in not found in this repository",
error.NotACheckin => "the named artifact is not a check-in",
};
}