Artifact 35278453dcc7c897cb2b1201e5a4870ed92dcae819a37a66a0de4dbd2ca939c5:
- File
src/cmd/checkout.zig
— part of check-in
[2772dd3942]
at
2026-08-18 02:18:20
on branch trunk
— ls, cat and bisect read the warm image instead of re-deriving (#98)
On tcl's tip zit ls drops from 58.3s to 0.31s: the derived state it needed was already in the cache, in the zero-parse image, and the verb asked for the record-body replay anyway.
Adjacency arrived in the image last change; what remained was every read that went around the accessors. events, its count, and store.count are all empty on an image backing, so a bound taken from them read zero and a branch value read from tagxref fell back to main_branch for every rid -- the section 8.4 walk then never left the branch and answered start:/root: with a check-in from somewhere else, exit 0 and no diagnostic. Those reads route through hasEvent, mtimeOf, hashOf, ridCount and eventsIter now, which name one enumeration order for both backings rather than inheriting insertion order from one of them.
info stays on the full derive: amend --comment stores its override as a tag, and the image carries no tag tables, so on the fast path info printed the manifest's original comment. An existing test caught that the moment the verb moved; branch and tag stay behind for the same reason. (user: dmestas, size: 251717) [annotate] [blame] [check-ins using]
//! 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, amb: cli.Ambient, ) 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 = cli.openStoreRead(io, root_dir) 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; var cone_not_members = parsed.cone_not_members; var cone_not_len = parsed.cone_not_len; if (parsed.cone_file) |cf| { const lens = cli.appendConeFile(arena, io, cf, &cone_members, cone_len, &cone_not_members, cone_not_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; }; cone_len = lens.members; cone_not_len = lens.not_members; } 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 + cone_not_len != 0) blk: { new_cone = zit.cone.Cone.init(gpa, cone_members[0..cone_len], cone_not_members[0..cone_not_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)); // #83 — the project's `manifest` setting supplies the stamp selection when // this checkout has none of its own, so a repository that adopts the // setting starts stamping at the next update rather than only after a // fresh `open`. A checkout that made its own choice keeps it. const project_manifest = projectManifest(gpa, io, arena, root_dir, amb); update.applyCheckin(gpa, io, root_dir, &log, target_id, .{ .inherit_or = project_manifest }, 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 = cli.openStoreRead(io, root_dir) 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_or = project_manifest }, sparse) catch |err2| return updateApplyFail(streams, target_id, err2); cli.stampWorktreeRegistry(gpa, io, root_dir, root.abs); // #70 return .ok; } } return updateApplyFail(streams, target_id, err); }; // #70 -- a successful update stamps this tree into the store's worktree // registry (best-effort; discovery data never gates the update). cli.stampWorktreeRegistry(gpa, io, root_dir, root.abs); 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 = cli.openStoreRead(io, dir) 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, amb: cli.Ambient, ) 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 {}; // The cone-file grammar's marker: `!member` is an exclusion. for (c.not_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..]; if (std.mem.eql(u8, action, "test")) { return coneTest(gpa, io, arena, paths, streams, root_dir, &stored); } 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); var not_members: std.ArrayListUnmanaged([]const u8) = .empty; defer not_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); } for (c.not_members) |m| { if (removing) { var drop = false; for (paths) |p2| { if (std.mem.eql(u8, m, p2)) drop = true; } if (drop) continue; } not_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 + not_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); } for (not_members.items) |m| { const flag = std.fmt.allocPrint(arena, "--cone-not={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, amb); } fn coneOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit cone: out of memory\n") catch {}; return .failure; } /// Largest `-` (stdin) path list `zit cone test` reads. Paths are not /// members, so the cone's 255-member cap does not bound this list; the bound /// exists so the read is finite by inspection rather than by trusting the /// writer at the other end of the pipe. Generous enough for the full file /// list of a very large tree. const max_cone_test_stdin: usize = 8 * 1024 * 1024; /// `zit cone test PATH...` (#79) — classify paths against a stored cone and /// name the member that decided each, materializing nothing. /// /// The verdict alone is all git's `sparse-checkout check-rules` can answer, /// because every match there is a hash lookup against a directory set. Here /// coverage is decided by the LONGEST member across both polarities, so the /// deciding member is the real answer to "why isn't this file here" — and it /// is exactly the token to hand `zit cone remove`, the usual next step. The /// member prints bare for that reason: the verdict column already carries the /// polarity (a named member under `elided` is necessarily an exclusion), so a /// marker would only make the column unpasteable. fn coneTest( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, args: []const []const u8, streams: cli.Streams, root_dir: std.Io.Dir, worktree_cone: *?zit.cone.Cone, ) cli.Exit { var use_store = false; var quiet = false; var read_stdin = false; var paths: std.ArrayListUnmanaged([]const u8) = .empty; defer paths.deinit(gpa); for (args) |a| { if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { streams.out.writeAll(main.cone_help) catch {}; return .ok; } else if (std.mem.eql(u8, a, "--store")) { use_store = true; } else if (std.mem.eql(u8, a, "--quiet")) { quiet = true; } else if (std.mem.eql(u8, a, "-")) { read_stdin = true; } else if (a.len >= 2 and a[0] == '-') { return cli.usageError(streams, "cone test", .unknown_option, a); } else { paths.append(gpa, a) catch return coneOom(streams); } } if (read_stdin) { const stdin = std.Io.File.stdin(); var read_buf: [64 * 1024]u8 = undefined; var reader = stdin.readerStreaming(io, &read_buf); const blob = reader.interface.allocRemaining(arena, .limited(max_cone_test_stdin)) catch { streams.err.print( "zit cone test: cannot read paths from stdin, or more than {d} bytes were given\n", .{max_cone_test_stdin}, ) catch {}; return .failure; }; var lines = std.mem.splitScalar(u8, blob, '\n'); while (lines.next()) |raw| { // Only the line ending is stripped: a leading or trailing space // is part of the path, and validation is what refuses it. const line = std.mem.trimEnd(u8, raw, "\r"); if (line.len == 0) continue; // A trailing newline is not a path. paths.append(gpa, line) catch return coneOom(streams); } } if (paths.items.len == 0) return cli.usageError(streams, "cone test", .no_paths, ""); // Validated before any classification, so a path that could never be // tracked is a usage error rather than a silent `elided` (§3.2 defers // member validation to the tracked-path rules; the same rules apply to // what we classify). for (paths.items) |p| { zit.control_paths.validateWorktreePath(p) catch { streams.err.print( "zit cone test: not a repository-relative path: {s}\n", .{p}, ) catch {}; return .usage; }; } // `--store` asks about the STORE cone, which is a different question: // a path present in the checkout but absent from the store is a real // condition, and the two cones need not agree. var store_cone = if (use_store) zit.repocache.readStoreCone(gpa, io, root_dir) else null; defer if (store_cone) |*c| c.deinit(gpa); const cone: ?*const zit.cone.Cone = if (use_store) (if (store_cone) |*c| c else null) else (if (worktree_cone.*) |*c| c else null); var width: usize = 0; for (paths.items) |p| width = @max(width, p.len); var any_elided = false; for (paths.items) |p| { // A dense cone elides nothing, so every path is covered and no member // decided it. Reporting that truthfully keeps the exit code usable as // a predicate: in a dense checkout the file really is there. const d: zit.cone.Decision = if (cone) |c| c.decide(p) else .{ .covered = true, .member = null }; if (!d.covered) any_elided = true; if (quiet) continue; const verdict = if (d.covered) "covered" else "elided"; const reason = if (d.member) |m| m else if (cone == null) "(dense)" else "(no member covers)"; streams.out.print("{s}", .{verdict}) catch {}; conePad(streams.out, verdict.len, 8); streams.out.print("{s}", .{p}) catch {}; conePad(streams.out, p.len, width + 2); streams.out.print("{s}\n", .{reason}) catch {}; } return if (any_elided) .failure else .ok; } /// Column padding for `cone test`, so the deciding member lines up under a /// list of unequal paths. Widths are computed from the input, never fixed. fn conePad(w: *std.Io.Writer, have: usize, want: usize) void { var i = have; while (i < want) : (i += 1) w.writeByte(' ') catch return; } /// 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 (exclusions included), at least one inclusion\n", .{verb}) catch {}, error.DeadExclusion => s.err.print("zit {s}: a --cone-not member no --cone member covers is a dead rule\n", .{verb}) catch {}, error.Contradiction => s.err.print("zit {s}: one path given as both --cone and --cone-not\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, true, &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, /// #81 — the file's CONTENT is a class this repository has not said it /// accepts: NUL bytes, or CR/LF line endings. Refused rather than warned, /// because a warning is a prompt and `commit` has to be answerable /// non-interactively; fossil reaches the same place, since its warning /// blocks a non-tty commit until `--no-warnings` is passed. content_class: ContentFault, oom, }; /// The content classes a commit will not record silently. const ContentFault = enum { binary, crlf, fn describe(self: ContentFault) []const u8 { return switch (self) { .binary => "contains NUL bytes (it looks binary)", .crlf => "has CR/LF line endings", }; } fn glob(self: ContentFault) []const u8 { return switch (self) { .binary => "binary-glob", .crlf => "crlf-glob", }; } }; /// #81 — the resolved content policy for one commit: which files may carry /// which content class, and whether the operator waived the check entirely. /// Both globs resolve through the ordinary tier chain, so a tracked /// `.zit-settings/binary-glob` applies to a fresh clone with no consent step — /// which is the whole reason these are DECLARATIVE rather than a /// `before-commit` script, since that one runs for nobody until `zit trust`. const ContentPolicy = struct { binary_glob: []const u8 = "", crlf_glob: []const u8 = "", waived: bool = false, /// The fault this file trips, or null when nothing objects. /// /// A class is checked ONLY when its glob is set. An unset glob is "this /// project has no opinion", not "refuse everything": committing a PNG is /// ordinary, and a check that fired by default would make every repository /// that never asked for it pay a two-step dance. Declaring the glob is what /// opts a project in, and it reads naturally — `binary-glob = *.png` says /// PNGs are the binaries this project expects, so a stray `.o` is refused. /// /// This departs from fossil, which warns on binary content by default and /// uses its globs to SUPPRESS the warning. The departure is deliberate: a /// warning that fires on every ordinary commit is one nobody reads, and a /// refusal nobody asked for is worse. zit checks when told to, and then /// actually refuses — which is what makes it a policy rather than noise. /// /// Binary is tested first: a binary file usually also contains CR/LF, and /// naming it "binary" is the diagnostic the operator can act on. fn fault(self: ContentPolicy, path: []const u8, content: []const u8) ?ContentFault { if (self.waived) return null; if (self.binary_glob.len != 0 and std.mem.indexOfScalar(u8, content, 0) != null and !zit.ignore.listMatches(self.binary_glob, path)) return .binary; if (self.crlf_glob.len != 0 and std.mem.indexOf(u8, content, "\r\n") != null and !zit.ignore.listMatches(self.crlf_glob, path)) return .crlf; return null; } }; /// 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, policy: ContentPolicy, ) 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); // #81 — policy gates what this commit INTRODUCES, not the tree it inherits. // An all-files commit re-captures every managed path, so classifying // unchanged content would make declaring a glob block every later commit // until the whole tree already conformed — adoption would be all-or- // nothing in exactly the repositories that most need it. Comparing against // the parent's blob for this path makes it incremental: existing files are // grandfathered, a new or edited one is not. Checked before the blob is // stored, so a refused commit mints nothing. const unchanged = if (base) |b| std.mem.eql(u8, &digest, log.hashOf(b)) else false; if (!unchanged) { if (policy.fault(key, content)) |f| return .{ .content_class = f }; } // §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, }; } /// #72/#83 — the two LOCAL signing settings, read from the same config frame /// everything else in the commit path reads. `clearsign` makes every commit /// signed without `--sign`, matching fossil; `pgp_command` is the stored /// command line, which `$ZIT_PGP_COMMAND` still overrides. Neither ever /// crosses the wire: fossil classifies `pgp-command` SENSITIVE (a propagated /// value would let an origin choose what a cloner executes) and puts /// `clearsign` in no config group at all. /// /// An unreadable config yields the defaults — off, and the built-in command — /// which is the same direction `submanifestEmission` takes for the same /// reason: guessing "sign" on a frame we could not read would fail the commit, /// not protect it. const SigningSettings = struct { clearsign: bool = false, /// Borrows the caller's arena; empty means the built-in default. pgp_command: []const u8 = "", }; /// #72/#83 — the two signing settings, resolved through the SAME precedence /// `zit settings` reports: environment, then this repository, then the /// operator's global file, then the built-in default. (Neither is versionable, /// so no tracked file can reach them — that boundary is enforced inside /// `settings.resolve`, not here.) /// /// Routed through the shared resolver rather than reading the config frame /// directly, because the two must never disagree: a `zit settings` that reports /// `clearsign on` from the global tier while the commit quietly does not sign is /// a worse failure than having no global tier at all. That bug existed for /// exactly one commit. /// /// An unresolvable setting yields the default — off, and the built-in command — /// the same direction `submanifestEmission` takes for the same reason: guessing /// "sign" on state we could not read would fail the commit, not protect it. fn signingSettings( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, amb: cli.Ambient, ) SigningSettings { const clear = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .clearsign) catch return .{}; const cmd = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .pgp_command) catch return .{ .clearsign = zit.settings.parseBool(clear.value) orelse false }; return .{ .clearsign = zit.settings.parseBool(clear.value) orelse false, // The resolver always answers with the default when no tier spoke, and // `clearsignBytes` treats an EMPTY stored command as "use the built-in". // Passing the resolved default through is the same thing, one fewer // special case. .pgp_command = cmd.value, }; } /// 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), under // the store's write lock — released by the explicit close after the // log commits, so autosync and the drift advisory below run unlocked. var file = cli.openStoreAppend(io, root_dir) catch return commitLogFail(streams, error.Storage); var file_open = true; defer if (file_open) 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); var log_open = true; defer if (log_open) 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; // #81 — resolved once for the whole commit, not per file: the tier chain // reads the working tree, and a policy that changed mid-commit would make // the refusal depend on capture order. `--no-checks` waives this the same // way it waives `before-commit` — one flag for "skip the commit-time // checks" rather than a second spelling for the same intent. Unreadable // settings resolve to empty, which REFUSES the odd content rather than // admitting it: the safe direction for a check, and the opposite of the // choice `signingSettings` makes, where guessing "sign" would fail a // commit that should have succeeded. const content_policy: ContentPolicy = .{ .binary_glob = (cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .binary_glob) catch zit.settings.Resolved{ .value = "", .source = .default }).value, .crlf_glob = (cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .crlf_glob) catch zit.settings.Resolved{ .value = "", .source = .default }).value, .waived = parsed.no_checks, }; 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), content_policy)) { .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), .content_class => |f| return commitContentClass(streams, path, f), .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), content_policy)) { .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), .content_class => |f| return commitContentClass(streams, key, f), .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: [23]u8 = undefined; const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &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 plain = artifact.create(&m, gpa) catch return commitOom(streams); defer gpa.free(plain); // #72 — `--sign`: clearsign the manifest through the operator's PGP // command and store the WRAPPED text as the artifact, fossil's own // behavior: the name hashes the wrapper too, readers strip it (§4.1.1) // to reach the cards, and the signature therefore survives every // byte-preserving path for free. The wrapped payload MUST strip back to // the exact input — a signer that rewrites the manifest (CRLF // normalization, dash-escaping a line, a charset filter) would sign // different bytes than the cards the reader interprets, so that is a // refusal, never a store. // #82 — the project's own pre-commit check, run BEFORE anything is minted // so a refusal costs nothing and leaves no artifact behind. switch (runBeforeCommit(gpa, io, arena, root_dir, streams, amb, parsed.no_checks)) { .ok => {}, else => |e| return e, } const signing = signingSettings(gpa, io, arena, root_dir, amb); // fossil's `clearsign` setting: sign every commit without the caller // asking. `--sign` on top of it is a no-op, not a doubling. const sign = parsed.sign or signing.clearsign; const bytes: []const u8 = if (!sign) plain else blk: { const wrapped = clearsignBytes(gpa, io, amb, root_dir, signing.pgp_command, plain) orelse return commitSignFail(streams); if (!std.mem.eql(u8, artifact.stripSignatureWrapper(wrapped), plain)) { gpa.free(wrapped); return commitSignFail(streams); } break :blk wrapped; }; defer if (sign) gpa.free(@constCast(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); // The append is durable; release the store lock before the steps that // reopen the store themselves (autosync's push, the §6.4 advisory's // retune write) — the OS lock is per open file description, so holding // this handle across those would wedge the process against itself. log.deinit(gpa); log_open = false; file.close(io); file_open = false; // 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'; } /// Bound on what a `before-commit` check may print. Generous -- a failing test /// run is exactly when an operator wants the whole output -- but finite, /// because an unbounded child could otherwise hold the commit open forever. const max_check_output: usize = 8 * 1024 * 1024; /// #82 — run the project's `before-commit` check, if there is one this /// checkout has agreed to run. /// /// Ordering matters: this runs before the manifest is composed, so a refusal /// mints nothing and the failed commit leaves the repository exactly as it was. /// /// The three outcomes an operator can meet: /// * no declaration, or one they wrote here -> run it (or nothing); /// * a versioned declaration they have not accepted -> REFUSE, with the /// command printed and the one command that accepts it; /// * a versioned declaration that CHANGED since they accepted it -> refuse /// and say so, because that is the case where a pull may have edited a /// script they already agreed to run. /// /// Refusing rather than warning is the deliberate choice. A warning lets an /// operator believe they are gated when they are not, and "I thought the tests /// ran" is a worse failure than "I had to type one command once". fn runBeforeCommit( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, streams: cli.Streams, amb: cli.Ambient, skip: bool, ) cli.Exit { const resolved = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .before_commit) catch return .ok; // unreadable settings must not invent a check if (resolved.value.len == 0) return .ok; if (skip) { // Loud on purpose, and it names the command that did not run: a silent // bypass is how `--no-verify` became a habit rather than a decision. streams.err.print( "zit commit: --no-checks: SKIPPING before-commit: {s}\n", .{resolved.value}, ) catch {}; return .ok; } const stored = trustRecord(gpa, io, arena, root_dir) catch ""; switch (zit.settings.trustVerdict(resolved.value, resolved.source, stored)) { .none => return .ok, .self_authored, .trusted => {}, .untrusted => { streams.err.print( "zit commit: this repository ships a before-commit check that has not been accepted here:\n {s}\n", .{resolved.value}, ) catch {}; streams.err.writeAll("read it, then run 'zit trust --yes' to accept it (or 'zit commit --no-checks' to skip once)\n") catch {}; return .failure; }, .changed => { streams.err.print( "zit commit: the before-commit check CHANGED since you accepted it:\n {s}\n", .{resolved.value}, ) catch {}; streams.err.writeAll("re-read it, then run 'zit trust --yes' to accept the new text\n") catch {}; return .failure; }, } // Run it against THIS worktree, through a shell so a pipeline works -- // which is the whole use case (`zig fmt --check src && zig build test`). // The command was explicitly trusted, so the shell is not a new exposure. var env_map = amb.environ.createMap(gpa) catch { streams.err.writeAll("zit commit: out of memory preparing before-commit\n") catch {}; return .failure; }; defer env_map.deinit(); const result = std.process.run(gpa, io, .{ .argv = &.{ "/bin/sh", "-c", resolved.value }, .cwd = .{ .dir = root_dir }, .environ_map = &env_map, .stdout_limit = .limited(max_check_output), }) catch { streams.err.print("zit commit: before-commit could not be run: {s}\n", .{resolved.value}) catch {}; return .failure; }; defer gpa.free(result.stdout); defer gpa.free(result.stderr); const failed = switch (result.term) { .exited => |code| code != 0, else => true, // signalled or stopped is not success }; // The check's OWN diagnostics are what an operator needs to fix the commit, // so they are relayed verbatim rather than summarised -- and on success too, // because a check that prints a warning has something to say either way. if (result.stdout.len != 0) streams.out.writeAll(result.stdout) catch {}; if (result.stderr.len != 0) streams.err.writeAll(result.stderr) catch {}; if (failed) { streams.err.writeAll("zit commit: before-commit failed; nothing was committed\n") catch {}; return .failure; } return .ok; } /// #83 — the project's `manifest` setting, or empty when it has none or cannot /// be read. Empty is the pre-#83 behaviour, so an unreadable setting degrades /// to "no project default" rather than failing an update: build stamps are a /// convenience, and losing one must never cost the operator their materialization. fn projectManifest( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, amb: cli.Ambient, ) []const u8 { const r = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .manifest) catch return ""; return r.value; } /// 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. /// #72 — clearsign `plain` through the operator's signing command, fossil's /// `clearsign()` contract byte for byte (fossil src/clearsign.c), so a /// `pgp-command` value that works there works here verbatim: /// /// * the command is `$ZIT_PGP_COMMAND`, else the repository's stored /// `pgp-command` setting, else `gpg --clearsign -o ` (fossil's own /// default, trailing space included); /// * it is invoked as `COMMAND OUTFILE INFILE` — the `-o ` in the default /// binds OUTFILE, and the manifest arrives as the positional INFILE; /// * a command whose basename starts with `ssh` is the DETACHED case /// (2025: ssh cannot make a non-detached signature): it is invoked as /// `COMMAND INFILE`, writes `INFILE.sig`, and the armor is assembled /// here — `-----BEGIN SSH SIGNED MESSAGE-----` + blank line + manifest /// + the detached signature, exactly fossil's assembly; /// * a false-ish command (`off`/`no`/`0`/`false`) means "do not sign". /// /// Returns the wrapped bytes (caller frees), or null on any failure. `null` /// is the SIGNING FAILURE signal; whether that aborts the commit is the /// caller's policy, not this function's. fn clearsignBytes( gpa: std.mem.Allocator, io: std.Io, amb: cli.Ambient, root_dir: std.Io.Dir, stored_command: []const u8, plain: []const u8, ) ?[]u8 { // Precedence, widest scope last: the environment overrides the repository's // stored `pgp-command`, which overrides fossil's built-in default. That is // fossil's own order — an operator must be able to redirect one invocation // without editing the repository. const command: []const u8 = amb.environ.getPosix("ZIT_PGP_COMMAND") orelse if (stored_command.len != 0) stored_command else "gpg --clearsign -o "; if (isFalseSetting(command)) return null; const ssh_style = std.mem.startsWith(u8, commandBasename(command), "ssh"); const in_name = ".zit-tmp-sign-in"; const out_name = ".zit-tmp-sign-out"; const sig_name = ".zit-tmp-sign-in.sig"; // ssh writes INFILE.sig { var f = root_dir.createFile(io, in_name, .{ .truncate = true }) catch return null; defer f.close(io); f.writePositionalAll(io, plain, 0) catch { root_dir.deleteFile(io, in_name) catch {}; return null; }; } defer { root_dir.deleteFile(io, in_name) catch {}; root_dir.deleteFile(io, out_name) catch {}; root_dir.deleteFile(io, sig_name) catch {}; } var abs_buf: [std.fs.max_path_bytes]u8 = undefined; const abs_len = root_dir.realPath(io, &abs_buf) catch return null; const root_abs = abs_buf[0..abs_len]; // Fossil interpolates the file names straight into the shell command, and // so does this — but the names are OURS (fixed literals under a resolved // root), never operator input, so there is nothing here for a quoted // path to protect against that single-quoting does not already cover. const script = if (ssh_style) std.fmt.allocPrint(gpa, "{s} '{s}/{s}'", .{ command, root_abs, in_name }) catch return null else std.fmt.allocPrint(gpa, "{s} '{s}/{s}' '{s}/{s}'", .{ command, root_abs, out_name, root_abs, in_name }) catch return null; defer gpa.free(script); var env_map = amb.environ.createMap(gpa) catch return null; defer env_map.deinit(); const result = std.process.run(gpa, io, .{ .argv = &.{ "/bin/sh", "-c", script }, .environ_map = &env_map, .stdout_limit = .limited(max_signature_bytes), }) catch return null; gpa.free(result.stdout); gpa.free(result.stderr); switch (result.term) { .exited => |c| if (c != 0) return null, else => return null, } if (!ssh_style) { return root_dir.readFileAlloc(io, out_name, gpa, .limited(max_signature_bytes)) catch null; } // SSH: assemble what ssh-keygen will not. Fossil's exact layout — armor // line, ONE blank line (no `Hash:` header), the manifest verbatim, then // the already-armored detached signature. const sig = root_dir.readFileAlloc(io, sig_name, gpa, .limited(max_signature_bytes)) catch return null; defer gpa.free(sig); var out: std.ArrayListUnmanaged(u8) = .empty; errdefer out.deinit(gpa); out.appendSlice(gpa, "-----BEGIN SSH SIGNED MESSAGE-----\n\n") catch return null; out.appendSlice(gpa, plain) catch return null; out.appendSlice(gpa, sig) catch return null; return out.toOwnedSlice(gpa) catch null; } /// One signature's ceiling: a manifest plus armor, never a payload channel. const max_signature_bytes: usize = 8 * 1024 * 1024; /// Fossil's `is_false()` over a setting value: the spellings that mean /// "disabled". Anything else is a command to run. fn isFalseSetting(v: []const u8) bool { const off = [_][]const u8{ "off", "no", "false", "0" }; for (off) |o| if (std.ascii.eqlIgnoreCase(v, o)) return true; return false; } /// The command word's basename — fossil's `command_basename()`, used only to /// spot the `ssh*` family. Reads up to the first space, then after the last /// path separator. fn commandBasename(command: []const u8) []const u8 { const word_end = std.mem.indexOfScalar(u8, command, ' ') orelse command.len; const word = command[0..word_end]; const slash = std.mem.lastIndexOfScalar(u8, word, '/') orelse return word; return word[slash + 1 ..]; } /// Renders the #72 signing refusal: the signer failed, or its output does /// not strip back to the exact manifest it was given. fn commitSignFail(s: cli.Streams) cli.Exit { s.err.writeAll("zit commit: --sign failed: the clearsign command errored or altered the manifest (set ZIT_PGP_COMMAND to override 'gpg --clearsign')\n") catch {}; return .failure; } 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; } /// #81 — the file's content is a class this repository has not accepted. Names /// the file, what it is, and BOTH ways forward: the durable one (record the /// glob, which then applies to every clone) and the one-off waiver. Refusing /// with only "use --no-warnings" would train the reflex that discards the check. fn commitContentClass(s: cli.Streams, path: []const u8, fault: ContentFault) cli.Exit { s.err.print("zit commit: refusing '{s}': it {s}\n", .{ path, fault.describe() }) catch {}; s.err.print( "zit commit: accept it for good with 'zit settings {s} \"{s}\"' (a tracked .zit-settings/{s} applies to every clone), or once with --no-checks\n", .{ fault.glob(), path, fault.glob() }, ) 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, amb: cli.Ambient, ) 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; } // Settings-writing actions operate on the repository the checkout names; // only `analyze` (a pure measurement) honors -R (#61) -- the conversion // gate's pre-flight runs against a bare cloned store. if (amb.repo_override != null and parsed.action != .analyze) { streams.err.writeAll("zit submanifest: -R/--repo works with 'analyze' only; settings live in the repository the checkout names\n") catch {}; return .failure; } if (parsed.replay_all and parsed.action != .analyze) { streams.err.writeAll("zit submanifest: --replay-all is an 'analyze' option\n") catch {}; return .usage; } const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return submanifestOom(streams)) orelse { main.reportNoRoot(streams, io, "submanifest", amb.repo_override); 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, parsed.replay_all, 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 = cli.openStoreRead(io, root_dir) 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 = cli.openStoreAppend(io, root_dir) 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, false, 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, replay_all: bool, 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, true, 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, if (replay_all) std.math.maxInt(usize) else replay_window, 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, window: usize, 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, 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, fallback_tip: bool, 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 blk: { // analyze over a bare store (-R, no checkout anywhere): the main // branch's tip file set is the natural measurement subject (#61). if (fallback_tip) { if (repo.resolve("tip", .checkin)) |tip| { streams.out.writeAll("no checkout open — analyzing the tip check-in's file set\n") catch {}; break :blk tip; } else |_| {} } 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 = cli.openStoreRead(io, root_dir) 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", }; } /// `zit worktrees` (#70) — list the store's registered working trees, lazily /// pruning entries whose path died or whose checkout re-pointed at some /// other store. The identity test is the store file's inode: an entry /// counts iff its own resolved store names the same `.zit` this invocation /// resolved. Pruning rewrites only the disposable cache tier. pub fn runWorktrees( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { streams.out.writeAll(main.worktrees_help) catch {}; return .ok; } return cli.usageError(streams, "worktrees", .extra_argument, tok); } const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "worktrees")) orelse { main.reportNoRoot(streams, io, "worktrees", amb.repo_override); return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // The store root: the resolved pointer for a detached tree, the root // itself otherwise (including the bare -R form, where root IS the store). var store_dir = blk: { if (checkout.storeDirOf(io, root_dir) catch null) |resolved| break :blk resolved; break :blk root_dir.openDir(io, ".", .{}) catch { streams.err.writeAll("zit worktrees: cannot open the repository\n") catch {}; return .failure; }; }; defer store_dir.close(io); const store_inode = (store_dir.statFile(io, zit.repo_filename, .{}) catch { streams.err.writeAll("zit worktrees: cannot read the repository\n") catch {}; return .failure; }).inode; const registered = zit.repocache.readWorktrees(gpa, io, store_dir); defer zit.repocache.freeWorktrees(gpa, registered); var live: std.ArrayListUnmanaged([]const u8) = .empty; defer live.deinit(gpa); for (registered) |path| { if (worktreeServesStore(io, path, store_inode)) { live.append(gpa, path) catch return stateOom(streams, "worktrees"); } } if (live.items.len != registered.len) { zit.repocache.writeWorktrees(gpa, io, store_dir, live.items); // lazy prune } if (live.items.len == 0) { streams.out.writeAll("no working trees are registered against this store\n") catch {}; return .ok; } for (live.items) |path| { // The invoking-tree marker only makes sense when the invocation RAN // in a tree; under -R the resolved root is the store, not us. const marker: []const u8 = if (amb.repo_override == null and std.mem.eql(u8, path, root.abs)) "* " else " "; var tree = std.Io.Dir.cwd().openDir(io, path, .{}) catch continue; defer tree.close(io); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = checkout.read(io, tree, &ck_buf) catch null; if (state) |st| { if (st.version.len != 0) { streams.out.print("{s}{s} ({s} at {s})\n", .{ marker, path, st.branch, st.version[0..@min(st.version.len, 10)] }) catch {}; continue; } streams.out.print("{s}{s} ({s}, unborn)\n", .{ marker, path, st.branch }) catch {}; continue; } streams.out.print("{s}{s}\n", .{ marker, path }) catch {}; } return .ok; } /// Does the checkout at `path` still serve the store identified by /// `store_inode`? Total: any failure reads as "no" and the entry prunes — /// the registry's self-healing contract. fn worktreeServesStore(io: std.Io, path: []const u8, store_inode: std.Io.File.INode) bool { var tree = std.Io.Dir.cwd().openDir(io, path, .{}) catch return false; defer tree.close(io); _ = tree.statFile(io, checkout.filename, .{}) catch return false; if (checkout.storeDirOf(io, tree) catch null) |resolved| { var sd = resolved; defer sd.close(io); const st = sd.statFile(io, zit.repo_filename, .{}) catch return false; return st.inode == store_inode; } const st = tree.statFile(io, zit.repo_filename, .{}) catch return false; return st.inode == store_inode; } // --------------------------------------------------------------------------- // #82 — `zit trust`: consent to execute a versioned before-commit declaration // --------------------------------------------------------------------------- /// `zit trust [--revoke]` — show, grant, or withdraw this checkout's consent to /// run the project's `before-commit` check. /// /// Bare, it SHOWS the declaration and whether it is trusted, and does not grant /// anything. Granting is a second, explicit invocation. That split is /// deliberate: consent given without seeing what is being consented to is not /// consent, and a single command that printed and trusted in one step would /// make the safe reading of `zit trust` the dangerous one. pub fn runTrust( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { var revoke = false; var grant = false; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { streams.out.writeAll(main.trust_help) catch {}; return .ok; } else if (std.mem.eql(u8, tok, "--revoke")) { revoke = true; } else if (std.mem.eql(u8, tok, "--yes")) { grant = true; } else { return cli.usageError(streams, "trust", .unknown_option, tok); } } if (revoke and grant) { streams.err.writeAll("zit trust: --revoke and --yes are contradictory\n") catch {}; return .usage; } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "trust")) orelse { streams.err.writeAll(cmd_sync.not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); const resolved = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .before_commit) catch { streams.err.writeAll("zit trust: cannot read the before-commit setting\n") catch {}; return .failure; }; if (revoke) { cmd_sync.updateConfig(gpa, io, root_dir, .{ .before_commit_trust = "" }) catch { streams.err.writeAll("zit trust: cannot write the trust record\n") catch {}; return .failure; }; streams.out.writeAll("before-commit: trust revoked\n") catch {}; return .ok; } if (resolved.value.len == 0) { streams.out.writeAll("before-commit: not set; nothing to trust\n") catch {}; return .ok; } const stored = trustRecord(gpa, io, arena, root_dir) catch ""; const verdict = zit.settings.trustVerdict(resolved.value, resolved.source, stored); if (!grant) { // SHOW. The declaration is printed in full because that is the thing // being consented to; a summary would defeat the point. streams.out.print("before-commit ({s}): {s}\n", .{ resolved.source.label(), resolved.value }) catch {}; switch (verdict) { .none => unreachable, // handled above .self_authored => streams.out.writeAll("status: runs (you set it here; no trust record needed)\n") catch {}, .trusted => streams.out.writeAll("status: trusted — runs before each commit\n") catch {}, .untrusted => { streams.out.writeAll("status: NOT trusted — commits are refused until you accept it\n") catch {}; streams.err.writeAll("read the command above, then run 'zit trust --yes' to accept it\n") catch {}; }, .changed => { streams.out.writeAll("status: CHANGED since you trusted it — commits are refused\n") catch {}; streams.err.writeAll("the declaration was edited (possibly by a pull); read it above, then 'zit trust --yes'\n") catch {}; }, } return .ok; } if (verdict == .self_authored) { streams.out.writeAll("before-commit: set locally; it already runs and needs no trust record\n") catch {}; return .ok; } var hash_buf: [64]u8 = undefined; const hash = zit.settings.declarationHash(resolved.value, &hash_buf); cmd_sync.updateConfig(gpa, io, root_dir, .{ .before_commit_trust = hash }) catch { streams.err.writeAll("zit trust: cannot write the trust record\n") catch {}; return .failure; }; streams.out.print("before-commit: trusted ({s})\n", .{hash[0..12]}) catch {}; return .ok; } /// This checkout's stored trust hash, or empty. Local-only by construction: /// it is read from the config frame, which never crosses a sync. fn trustRecord( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: std.Io.Dir, ) ![]const u8 { var file = cli.openStoreRead(io, root_dir) catch return ""; defer file.close(io); const st = file.stat(io) catch return ""; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch return ""; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch return "") orelse return ""; defer oc.deinit(gpa); return arena.dupe(u8, oc.config.before_commit_trust) catch ""; } test "commit #81: ContentPolicy classifies binary before crlf, and a glob permits each" { // Ordering matters: a binary file usually also contains CR/LF, and naming // it "binary" is the diagnostic the operator can act on. const both = "a\x00b\r\n"; // Undeclared is no opinion: a repository that never asked keeps committing // binary content exactly as before. This is the whole opt-in rule. const silent: ContentPolicy = .{}; try std.testing.expectEqual(@as(?ContentFault, null), silent.fault("x.dat", both)); // Declared: binary first, since a binary file usually also has CR/LF. const declared: ContentPolicy = .{ .binary_glob = "*.ok", .crlf_glob = "*.ok" }; try std.testing.expectEqual(ContentFault.binary, declared.fault("x.dat", both).?); try std.testing.expectEqual(ContentFault.crlf, declared.fault("x.txt", "a\r\nb").?); try std.testing.expectEqual(@as(?ContentFault, null), declared.fault("x.txt", "plain\n")); // Each glob permits only its own class, and only its own paths. const bin_ok: ContentPolicy = .{ .binary_glob = "*.dat" }; try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.fault("x.dat", "a\x00b")); try std.testing.expectEqual(ContentFault.binary, bin_ok.fault("x.bin", "a\x00b").?); // Declaring only binary-glob leaves CR/LF undeclared, hence unchecked. try std.testing.expectEqual(@as(?ContentFault, null), bin_ok.fault("x.dat", "a\r\nb")); const crlf_ok: ContentPolicy = .{ .crlf_glob = "*.txt" }; try std.testing.expectEqual(@as(?ContentFault, null), crlf_ok.fault("x.txt", "a\r\nb")); // crlf-glob alone leaves the binary class undeclared, so it stays silent. try std.testing.expectEqual(@as(?ContentFault, null), crlf_ok.fault("x.bin", "a\x00b")); // --no-checks waives everything, which is the only way past a class with // no glob — and is deliberately one flag, not one per class. const waived: ContentPolicy = .{ .binary_glob = "*.ok", .crlf_glob = "*.ok", .waived = true }; try std.testing.expectEqual(@as(?ContentFault, null), waived.fault("x.bin", both)); }