Artifact 8e2681d43681400a13b3e8155bfada5d850c5d09018a66d1ff4e3324e02492bb:
- File
src/cmd/checkout.zig
— part of check-in
[0b7b4af7fe]
at
2026-08-07 18:20:01
on branch sub-manifest
— update: name the file staging cannot materialize
Three separate sites printed the identical 'cannot read repository: repository is corrupt' -- deriving the view, reopening the log, and applyCheckin. So a materialization failure was indistinguishable from a failure to read the repository, which is a different claim about a different subsystem.
stagePlan's three refusals now return FileContentMissing carrying the file path and the artifact name: a file the check-in references has no artifact here, or has none stored, or the stored bytes do not match the name. verify cannot surface any of these -- it checks that stored artifacts are valid, never that referenced ones exist.
Being in the closed ApplyError set, the compiler required all four verb mappers to handle it. (#42) (user: dmestas, size: 163285) [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("../cli.zig"); const merge3 = @import("../merge3.zig"); const logstore = @import("zit").logstore; const storage = @import("zit").storage; const update = @import("zit").update; const checkout = @import("zit").checkout; const bisect = @import("zit").bisect; const Repository = @import("zit").Repository; const Io = std.Io; const File = Io.File; const repostate = zit.repostate; const managed = zit.managed; const artifact = zit.artifact; // Shared CLI helpers, constants, and verb help text that stay in main.zig // (other verbs use them too, or the dispatch help table references them); // aliased back so the moved code and tests reference them by their original // unqualified names. const main = @import("../main.zig"); const reasonOf = main.reasonOf; const stateOom = main.stateOom; const checkoutFail = main.checkoutFail; const managedFail = main.managedFail; const repoOpenFail = main.repoOpenFail; const openRepo = main.openRepo; const firstCommentLine = main.firstCommentLine; const lessByHash = main.lessByHash; const mintControlArtifact = main.mintControlArtifact; const hexToDigest = main.hexToDigest; const updateApplyFail = main.updateApplyFail; const trackedFileSizeAllowed = main.trackedFileSizeAllowed; const executable = main.executable; const digest_len = main.digest_len; const sym_prefix = main.sym_prefix; const timeline_hash_display = main.timeline_hash_display; const not_a_repo_msg = main.not_a_repo_msg; const repo_filename = main.repo_filename; const max_tracked_file_bytes = main.max_tracked_file_bytes; const symlink_unversioned = main.symlink_unversioned; const update_help = main.update_help; const merge_help = main.merge_help; const bisect_help = main.bisect_help; const revert_help = main.revert_help; const undo_help = main.undo_help; const redo_help = main.redo_help; const amend_help = main.amend_help; const commit_help = main.commit_help; /// The parent's symbolic tags a branch-root check-in must cancel (repo-state /// §6.2). §6.2: "Branch creation MUST also cancel every pre-existing active /// sym-* tag on the branch-root check-in ... because symbolic tags are /// otherwise INHERITED from a check-in's parent." Inheritance is propagation, /// so the cancel set is exactly the parent's PROPAGATING (`*`) syms: a singleton /// (`+`) sym never propagates, so the child does not inherit it and cancelling /// it would emit a spurious non-canonical `-sym-X` for a tag the child never /// held. `zit tag add` mints exactly such a singleton sym (REGRESSION B6), so /// this exclusion is reachable locally now, not only via a synced /// `T +sym-X *`. The branch's own re-established sym (`want_sym`) is excluded, /// since `composeCommitTags` re-adds it as a propagating tag. Pure; each /// surfaced name is duped onto `arena` so it outlives the read view. `entries` /// come from `RepoState.tagsOf`, which surfaces active rows only (no cancels). fn selectInheritedSyms( arena: std.mem.Allocator, entries: []const repostate.RepoState.TagEntry, want_sym: []const u8, ) error{OutOfMemory}![]const []const u8 { std.debug.assert(std.mem.startsWith(u8, want_sym, sym_prefix)); var syms: std.ArrayList([]const u8) = .empty; errdefer syms.deinit(arena); for (entries) |e| { std.debug.assert(e.tagtype != .cancel); // tagsOf surfaces active rows only if (e.tagtype != .propagate) continue; // §6.2: only inherited (propagating) syms if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue; if (std.mem.eql(u8, e.name, want_sym)) continue; // re-established as a propagating tag try syms.append(arena, try arena.dupe(u8, e.name)); } return syms.toOwnedSlice(arena); } test "commit repo-state §6.2: only inherited (propagating) parent syms are cancelled" { const gpa = std.testing.allocator; const TagEntry = repostate.RepoState.TagEntry; // A parent (as `tagsOf` surfaces it: active rows only) carrying every case: // an inherited propagating sym (MUST cancel), the branch's own propagating // sym (excluded — re-established), a SINGLETON sym (MUST NOT cancel; §6.2 // cancels only inherited syms, and a `+` sym never propagates), and a // non-sym propagating tag (`branch`, not a symbolic name). const entries = [_]TagEntry{ .{ .name = "sym-trunk", .tagtype = .propagate, .value = null }, .{ .name = "sym-feature", .tagtype = .propagate, .value = null }, .{ .name = "sym-adhoc", .tagtype = .singleton, .value = null }, .{ .name = "branch", .tagtype = .propagate, .value = "trunk" }, }; const got = try selectInheritedSyms(gpa, &entries, "sym-feature"); defer { for (got) |s| gpa.free(s); gpa.free(got); } // Exactly the inherited propagating sym, minus the re-established one: the // singleton `sym-adhoc` and the non-sym `branch` are both excluded. try std.testing.expectEqual(@as(usize, 1), got.len); try std.testing.expectEqualStrings("sym-trunk", got[0]); } /// `zit update CHECKIN` — parse one canonical artifact ID, discover the /// worktree, open the repository read-only, and delegate the transaction to /// the private update module. pub fn runUpdate( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const parsed = switch (cli.parseUpdateArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "update", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "update", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(update_help) catch {}; return .ok; } const requested = parsed.checkin orelse return cli.usageError(streams, "update", .no_checkin, ""); // SF1 (BLOCKER-FIX-PLAN.md §3): `update` used to accept ONLY a canonical // 40/64-hex ID, so `zit update trunk` — the single most obvious thing to // type — failed as an "invalid check-in ID". Its argument is a check-in // name like every other verb's, so it goes through the same §8.3 procedure. const target_id = switch (updateResolveTarget(gpa, io, arena, requested, streams)) { .ok => |h| h, .fail => |exit| return exit, }; const root = (cli.discoverRoot(arena, io) catch { streams.err.writeAll("zit update: out of memory\n") catch {}; return .failure; }) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var file = root_dir.openFile(io, repo_filename, .{}) catch { streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {}; return .failure; }; defer file.close(io); const log_st = file.stat(io) catch { streams.err.writeAll("zit update: cannot read repository: input/output error\n") catch {}; return .failure; }; var fs = storage.FileStorage.init(io, file, log_st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |err| return updateLogFail(streams, err); defer log.deinit(gpa); // The mutation entry: `target_id` is a full §3.6 artifact id — either the // canonical argument or the resolved-and-duped hash (both validated above). std.debug.assert(artifact.validateName(target_id)); update.applyCheckin(gpa, io, root_dir, &log, target_id, .inherit) catch |err| return updateApplyFail(streams, target_id, err); return .ok; } /// 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-bisect 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) { 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.TargetNotManifest, error.BaselineNotFound, error.BaselineContentMissing, error.BaselineNameMismatch, error.BaselineNotManifest, error.BaselineIsDelta, => return bisectDiag(s, "cannot read repository: repository is corrupt"), } } /// Resolve every stored mark hash to its rid. `Missing` == the session names a /// hash this view never interned (an inconsistent state). fn bisectRids(gpa: std.mem.Allocator, repo: *Repository, hashes: []const []const u8) error{ OutOfMemory, Missing }![]Repository.Rid { const out = gpa.alloc(Repository.Rid, hashes.len) catch return error.OutOfMemory; errdefer gpa.free(out); for (hashes, out) |h, *r| r.* = repo.lookup(h) orelse return error.Missing; return out; } /// Drop every occurrence of `hash` from `list` (content compare), so re-marking a /// check-in moves it between the good/bad/skip sets rather than duplicating it. fn bisectRemove(list: *std.ArrayList([]const u8), hash: []const u8) void { var i: usize = 0; while (i < list.items.len) { if (std.mem.eql(u8, list.items[i], hash)) { _ = list.orderedRemove(i); } else i += 1; } } // =========================================================================== // `zit annotate PATH` (contracts/annotate.md B-annotate-1..16). Per-line // provenance of one tracked file at the current check-out: which check-in // introduced each line, and when. The version chain is the file's FULL ancestry // materialized through `Repository.fileAt` (the §8 effective set) + `blob`, with // adjacency from the repostate §3.3 parent query; adjacent versions are diffed // by the ONE house line-diff core (`merge3.matchLines`); the pure `annotate` // engine pushes origins backward per ambiguities.md `annotate-attribution`. This // layer is only I/O + rendering — no second history walker, resolver, or diff. // =========================================================================== pub fn runBisect( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const parsed = switch (cli.parseBisectArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "bisect", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "bisect", .extra_argument, tok), .bad_subcommand => |tok| return cli.usageError(streams, "bisect", .bad_subcommand, tok), .missing_name => return cli.usageError(streams, "bisect", .no_name, ""), }; if (parsed.help) { streams.out.writeAll(bisect_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "bisect", null, false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); return switch (parsed.sub) { .bad => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .bad), .good => bisectMark(gpa, io, &repo, root_dir, streams, parsed.name.?, .good), .next => bisectNext(gpa, io, &repo, root_dir, streams), .status => bisectStatus(gpa, io, &repo, root_dir, streams), .reset => bisectReset(gpa, io, &repo, root_dir, streams), }; } /// `zit bisect bad|good NAME`: record the mark, then — once both a bad and a good /// endpoint exist — narrow the span and move the checkout to the next probe /// (B-bisect-1/3). A lone endpoint is recorded and confirmed without moving. fn bisectMark( gpa: std.mem.Allocator, io: std.Io, repo: *Repository, root_dir: std.Io.Dir, streams: cli.Streams, name: []const u8, kind: cli.BisectSub, ) cli.Exit { const rid = repo.resolve(name, .checkin) catch |e| switch (e) { error.NotFound => { streams.err.print("zit bisect: check-in '{s}' not found\n", .{name}) catch {}; return .failure; }, error.Ambiguous => { streams.err.print("zit bisect: name '{s}' is ambiguous\n", .{name}) catch {}; return .failure; }, error.OutOfMemory => return stateOom(streams, "bisect"), }; const hash = repo.hashOf(rid); var sess_opt = bisect.read(gpa, io, root_dir) catch |e| return bisectStateFail(streams, e); defer if (sess_opt) |*s| s.deinit(); var work = std.heap.ArenaAllocator.init(gpa); defer work.deinit(); const a = work.allocator(); var bad_list: std.ArrayList([]const u8) = .empty; var good_list: std.ArrayList([]const u8) = .empty; var skip_list: std.ArrayList([]const u8) = .empty; var start: []const u8 = ""; if (sess_opt) |s| { start = s.start; for (s.bad) |x| bad_list.append(a, x) catch return bisectOom(streams); for (s.good) |x| good_list.append(a, x) catch return bisectOom(streams); for (s.skipped) |x| skip_list.append(a, x) catch return bisectOom(streams); } else { // Fresh session: the current checkout is the restore point for `reset`. var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; if (checkout.read(io, root_dir, &ck_buf) catch null) |ck| { start = a.dupe(u8, ck.version) catch return bisectOom(streams); } } const dh = a.dupe(u8, hash) catch return bisectOom(streams); bisectRemove(&bad_list, dh); bisectRemove(&good_list, dh); bisectRemove(&skip_list, dh); switch (kind) { .bad => bad_list.append(a, dh) catch return bisectOom(streams), .good => good_list.append(a, dh) catch return bisectOom(streams), else => unreachable, } const has_both = bad_list.items.len != 0 and good_list.items.len != 0; if (!has_both) { // Record the lone endpoint; no move (B-bisect-1 first half). bisect.write(gpa, io, root_dir, start, bad_list.items, good_list.items, skip_list.items) catch |e| return bisectStateFail(streams, e); streams.out.print("bisect: marked {s} {s}\n", .{ bisectShort(hash), @tagName(kind) }) catch {}; const other = if (kind == .bad) "good" else "bad"; streams.out.print("bisect: mark a {s} check-in to begin bisecting\n", .{other}) catch {}; return .ok; } return bisectAdvance(gpa, io, repo, root_dir, streams, start, bad_list.items, good_list.items, skip_list.items, true); } /// Derive the span, refuse over a dirty tree / inverted / unrelated marks /// (mutating nothing), then — only on success — persist the state and move the /// checkout to the probe (or the culprit when the span is exhausted). Shared by /// marking (persist == true) and `next` (persist == true, idempotent rewrite). fn bisectAdvance( gpa: std.mem.Allocator, io: std.Io, repo: *Repository, root_dir: std.Io.Dir, streams: cli.Streams, start: []const u8, bad_hashes: []const []const u8, good_hashes: []const []const u8, skip_hashes: []const []const u8, persist: bool, ) cli.Exit { // Precondition: both endpoints exist — a caller only advances once a bad AND a // good mark are present (`bisectMark`'s `has_both`, `bisectNext`'s guard), and // `bisectSpan` needs both to bound the span. std.debug.assert(bad_hashes.len != 0 and good_hashes.len != 0); // Dirty-tree refusal first — the shipped update discipline (B-bisect-9), // mutating nothing. const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "bisect", e); update.requireCleanTree(gpa, io, root_dir, log) catch |e| switch (e) { error.WorktreeNotClean => return bisectDiag(streams, "working tree is not clean"), else => return bisectMoveFail(streams, e), }; const bad_rids = bisectRids(gpa, repo, bad_hashes) catch |e| switch (e) { error.OutOfMemory => return bisectOom(streams), error.Missing => return bisectInconsistent(streams), }; defer gpa.free(bad_rids); const good_rids = bisectRids(gpa, repo, good_hashes) catch |e| switch (e) { error.OutOfMemory => return bisectOom(streams), error.Missing => return bisectInconsistent(streams), }; defer gpa.free(good_rids); const skip_rids = bisectRids(gpa, repo, skip_hashes) catch |e| switch (e) { error.OutOfMemory => return bisectOom(streams), error.Missing => return bisectInconsistent(streams), }; defer gpa.free(skip_rids); const out = repo.bisectSpan(gpa, bad_rids, good_rids, skip_rids) catch |e| switch (e) { error.OutOfMemory => return bisectOom(streams), error.InvertedMarks => return bisectDiag(streams, "the bad check-in is an ancestor of a good check-in; the marks are inverted"), error.UnrelatedHistories => return bisectDiag(streams, "the good and bad marks share no common history"), }; // Persist the validated state durably BEFORE the move (B-bisect-6): a crash // leaves the marks recorded and the probe re-derivable. if (persist) { bisect.write(gpa, io, root_dir, start, bad_hashes, good_hashes, skip_hashes) catch |e| return bisectStateFail(streams, e); } if (out.probe) |probe| { const target = repo.hashOf(probe); update.applyCheckin(gpa, io, root_dir, log, target, .inherit) 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) 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) 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, ""); // 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. 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; var it = slots.iterator(); while (it.next()) |kv| { const path = kv.key_ptr.*; const slot = kv.value_ptr.*; // 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). std.debug.assert((spec.merge_parent.len == 0) != (spec.special_op.len == 0)); update.applyMerge(gpa, io, root_dir, entries.items, .{ .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); // 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.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.TargetNotManifest, error.BaselineNotFound, error.BaselineContentMissing, error.BaselineNameMismatch, error.BaselineNotManifest, error.BaselineIsDelta, => s.err.writeAll("zit merge: cannot read repository: repository is corrupt\n") catch {}, } return .failure; } // ========================================================================== // `zit revert` — restore managed files to their content at the current // check-in (artifact §8.4 effective file + §7.2 verified blob). A thin consumer // of the Repository read path (`fileAt`/`blob`) plus `update.restoreFile`: no // format work, no managed-set mutation. Content-restore only. // ========================================================================== /// `zit revert [PATH...]` — restore managed files to their checked-in content. /// With no paths, every managed file; with paths, only the named managed files. pub fn runRevert( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const parsed = switch (cli.parseRevertArgs(gpa, verb_args) catch return revertOom(streams)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "revert", .unknown_option, tok), }; defer gpa.free(parsed.paths); if (parsed.help) { streams.out.writeAll(revert_help) catch {}; return .ok; } // Discover the root (its absolute path drives path-scoped `repoRelative`), // then open the read-only derived view. const root = (cli.discoverRoot(arena, io) catch return revertOom(streams)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "revert", e); defer repo.deinit(gpa); // §7.5: the current checkout names the check-in whose content is restored; // an unborn/absent checkout has nothing to revert to. const rid = repo.currentCheckoutRid() orelse return revertNoCheckout(streams); // The managed set — read, never written: revert restores content only. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "revert", e); defer set.deinit(); // Gather the resolved restore targets first, so the undo journal captures // every path's pre-revert bytes in ONE seam before the first file is // rewritten (B-undo-2/14). Untracked or unresolvable args are reported here // and excluded from both the journal image and the restore. var targets: std.ArrayList([]const u8) = .empty; defer targets.deinit(gpa); var had_error = false; if (parsed.paths.len == 0) { for (set.iterate()) |path| targets.append(gpa, path) catch return revertOom(streams); } else { const cwd_abs = (cli.currentPathAlloc(arena, io) catch return revertOom(streams)) orelse { streams.err.writeAll("zit revert: cannot determine the current directory\n") catch {}; return .failure; }; for (parsed.paths) |arg| { const key = switch (cli.repoRelative(arena, root.abs, cwd_abs, arg) catch return revertOom(streams)) { .key => |k| k, .root, .outside => { revertNotTracked(streams, arg); had_error = true; continue; }, }; if (!set.contains(key)) { revertNotTracked(streams, key); had_error = true; continue; } targets.append(gpa, key) catch return revertOom(streams); } } // Every restore target is a managed path (gathered from the set, or gated on // `set.contains` for an explicit arg) — revert never rewrites an untracked file. if (std.debug.runtime_safety) { for (targets.items) |t| std.debug.assert(set.contains(t)); } // Journal-before-mutate (B-undo-3): the undo image is written and fsynced // before the first restore. A journal fault aborts with nothing mutated — // the durability rule forbids restoring without a recoverable pre-image. if (targets.items.len != 0) { update.journal.record(gpa, io, root_dir, targets.items) catch |e| return revertJournalFail(streams, e); } for (targets.items) |name| { if (revertOne(gpa, io, &repo, root_dir, rid, name, streams) == .failed) had_error = true; } return if (had_error) .failure else .ok; } /// A journal-write failure before revert touches any file — exit 1, nothing /// mutated. `record` only surfaces `OutOfMemory`/`Storage`; the `else` folds the /// device faults into the honest I/O message. fn revertJournalFail(s: cli.Streams, e: update.journal.JournalError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {}, else => s.err.writeAll("zit revert: cannot write the undo journal: input/output error\n") catch {}, } return .failure; } /// The per-file revert verdict: content restored, nothing to restore (absent /// from the effective set — informational, not an error), or a genuine fault. const RevertOutcome = enum { reverted, skipped, failed }; /// Restore one managed file `name` to its content at `rid`. §8.4 effective /// lookup: a file absent from the effective set (never present, or a §4.7.6 /// delete marker) has no content to restore — reported and left unchanged, /// never fabricated (`.skipped`). A read/write fault is `.failed`. fn revertOne( gpa: std.mem.Allocator, io: std.Io, repo: *Repository, root_dir: std.Io.Dir, rid: Repository.Rid, name: []const u8, streams: cli.Streams, ) RevertOutcome { const found = repo.fileAt(gpa, rid, name) catch |e| { revertFileFail(streams, name, e); return .failed; }; const card = found orelse { streams.err.print("zit revert: '{s}' is not in the current check-in; left unchanged\n", .{name}) catch {}; return .skipped; }; defer Repository.freeFileCard(gpa, card); std.debug.assert(card.hash != null); // fileAt yields only hash-bearing cards (§4.7.6) // §7.2: the blob is verified to hash to its F-card id before it lands. const bytes = (repo.blob(gpa, card.hash.?) catch |e| { revertFileFail(streams, name, e); return .failed; }) orelse { streams.err.print("zit revert: content for '{s}' is missing; left unchanged\n", .{name}) catch {}; return .failed; }; defer gpa.free(bytes); update.restoreFile(io, root_dir, name, bytes, card.mode) catch |e| { switch (e) { error.SymlinkUnsupported => streams.err.print( "zit revert: '{s}' is a symlink, which zit does not support; left unchanged\n", .{name}, ) catch {}, error.Storage => streams.err.print( "zit revert: cannot restore '{s}': input/output error\n", .{name}, ) catch {}, } return .failed; }; streams.out.print("reverted {s}\n", .{name}) catch {}; return .reverted; } fn revertOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit revert: out of memory\n") catch {}; return .failure; } fn revertNoCheckout(s: cli.Streams) cli.Exit { s.err.writeAll("zit revert: nothing to revert to (the working checkout is unborn)\n") catch {}; return .failure; } fn revertNotTracked(s: cli.Streams, path: []const u8) void { s.err.print("zit revert: '{s}' is not tracked (use 'zit add' to track it)\n", .{path}) catch {}; } /// Maps a `Repository.fileAt`/`blob` failure to a per-file diagnostic (no exit /// code — the caller aggregates). Switches the full `FilesError` set /// exhaustively; `blob`'s narrower `OpenError` coerces in. Phrasings match /// `ls`/`cat`. fn revertFileFail(s: cli.Streams, name: []const u8, e: Repository.FilesError) void { switch (e) { error.OutOfMemory => s.err.writeAll("zit revert: out of memory\n") catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.print("zit revert: cannot read '{s}': input/output error\n", .{name}) catch {}, error.Corrupt => s.err.print("zit revert: cannot read '{s}': repository is corrupt\n", .{name}) catch {}, error.NotManifest => s.err.writeAll("zit revert: not a check-in\n") catch {}, error.BaselineUnresolvable => s.err.writeAll("zit revert: cannot resolve the baseline of a delta check-in\n") catch {}, error.BaselineIsDelta => s.err.writeAll("zit revert: the baseline of a delta check-in is itself a delta\n") catch {}, } } // ========================================================================== // `zit undo` / `zit redo` — single-level undo/redo of the working-file mutators // (contracts/undo.md B-undo-1..21). Thin wiring: parse (help-only, no // positionals), discover the root BEFORE any journal read (B-undo-19), then // drive the `update.journal` swap. All format/slot work lives in `undo.zig`; // restoration reuses the update layer. No log append, no network. // ========================================================================== const UndoKind = enum { undo, redo }; /// `zit undo` — reverse the last sheltered working-file mutation. pub fn runUndo( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { return runUndoRedo(gpa, io, arena, verb_args, streams, .undo); } /// `zit redo` — reapply the change a preceding `zit undo` reversed. pub fn runRedo( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { return runUndoRedo(gpa, io, arena, verb_args, streams, .redo); } /// The shared body: the two verbs differ only in name, help text, which journal /// slot they consume, and the "nothing to …" phrasing (Ousterhout: one /// mechanism, not two near-identical copies). fn runUndoRedo( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, kind: UndoKind, ) cli.Exit { const verb = switch (kind) { .undo => "undo", .redo => "redo", }; const help_text = switch (kind) { .undo => undo_help, .redo => redo_help, }; // Usage errors are exit 2, stderr-only, and read no journal (B-undo-17/18); // `-h`/`--help` win over any trailing token and print to stdout (B-undo-16). const parsed = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, verb, .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, verb, .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(help_text) catch {}; return .ok; } // B-undo-19: the repository check precedes any journal read; absent → the // standard not-a-zit-repository diagnostic, exit 1 (distinct from exit 2). const root = (cli.discoverRoot(arena, io) catch { streams.err.print("zit {s}: out of memory\n", .{verb}) catch {}; return .failure; }) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); (switch (kind) { .undo => update.journal.undo(gpa, io, root_dir), .redo => update.journal.redo(gpa, io, root_dir), }) catch |err| return undoFail(streams, verb, err); // The single confirmation row goes to stdout (B-undo-20); diagnostics never // share the stream. streams.out.print("{s}: working tree restored\n", .{verb}) catch {}; return .ok; } /// Renders a `JournalError` to stderr and returns `.failure` (exit 1). `Empty` /// is the nothing-to-undo/redo case (B-undo-9/10); `Corrupt` the damaged /// journal (B-undo-11); the rest are honest device/allocation faults. fn undoFail(s: cli.Streams, verb: []const u8, err: update.journal.JournalError) cli.Exit { switch (err) { error.Empty => s.err.print("zit {s}: nothing to {s}\n", .{ verb, verb }) catch {}, error.Corrupt => s.err.print("zit {s}: the undo journal is damaged\n", .{verb}) catch {}, error.Storage => s.err.print("zit {s}: cannot restore working tree: input/output error\n", .{verb}) catch {}, error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, } return .failure; } /// One managed file captured for the check-in, or the reason it cannot be /// (rendered by the caller so the stderr/exit discipline stays in `runCommit`). const Capture = union(enum) { /// The file's F card (hash duped onto the manifest arena). card: artifact.FCard, missing, // absent from the working tree (B-commit-10) /// Present but not a regular file (B-commit-10). The payload is `true` for /// a symlink, which gets the F-B3 recovery hint instead of the bare /// refusal — carried here rather than re-stat'd by the renderer. not_regular: bool, io_error, // a read/store device failure (B-commit-15) /// Larger than one artifact frame can hold (`managed.max_tracked_file_bytes`). /// Distinct from `io_error` because it is neither a device fault nor /// anything the operator can retry: it is a stated limit, and reporting it /// as "input/output error" named nothing they had done and suggested a /// failure that might not recur. too_large: u64, oom, }; /// Stats, reads, hashes, and stores one managed file `key`, returning its F card /// or the reason it could not be captured. All of `commit`'s working-tree read /// I/O lives here (design-seams rule). Symlinks are not followed, so a symlink /// is `not_regular`, never silently resolved. The blob is stored uncompressed /// (the name is over uncompressed bytes; storage compression is deferred — /// Scope). Idempotent re-intern: `logstore.put` appends nothing for a blob /// already present. fn captureFile( gpa: std.mem.Allocator, io: std.Io, ma: std.mem.Allocator, root_dir: std.Io.Dir, log: *logstore.LogStore, key: []const u8, base: ?logstore.Rid, ) Capture { const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .missing; if (st.kind != .file) return .{ .not_regular = st.kind == .sym_link }; if (!trackedFileSizeAllowed(st.size)) return .{ .too_large = st.size }; const content = root_dir.readFileAlloc(io, key, gpa, .limited(max_tracked_file_bytes)) catch |e| return switch (e) { error.OutOfMemory => .oom, else => .io_error, }; defer gpa.free(content); // The on-disk log keys frames by the binary digest (logstore.max_hash_len == // 32); the F card carries the 64-hex artifact name (§4.7.1 wire form). Both // derive from the same content, so naming is unaffected by storage. var digest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); var name_buf: [64]u8 = undefined; const name = artifact.artifactName(.sha3_256, content, &name_buf); // §4.1/§7.2 — store the blob, delta-encoded against the same path's blob in // the parent check-in when that is materially smaller (else a full §4.1 // container). `base` is a hint only; naming is always over the uncompressed // bytes, so storage compression/delta never changes HASH. _ = log.putArtifact(&digest, content, base) catch |e| return switch (e) { error.OutOfMemory => .oom, error.Storage => .io_error, }; const hash = ma.dupe(u8, name) catch return .oom; const filename = ma.dupe(u8, key) catch return .oom; // Capture the working-tree executable bit so a chmod +x round-trips into the // check-in instead of silently reverting to regular (a permission drift that // otherwise shows as `M` on every diff and never commits). §4.7.6: a perm // token containing `x` marks the entry executable, and a producer SHOULD emit // only w/x/l — so an executable file carries the explicit `x` token (needed // for the mode to survive re-parse via `fileMode`), and a regular file omits // the token. Non-regular kinds already returned `.not_regular` above, so the // mode is only ever regular or executable here. const is_exec = executable(st.permissions); return .{ .card = .{ .filename = filename, .hash = hash, .perm = if (is_exec) "x" else null, .old_name = null, .mode = if (is_exec) .executable else .regular, } }; } /// `submanifestEmission`'s full result: the §8.1/§6.3 emission decision this /// commit builds against, plus `submanifest-auto-retune` read from the exact /// same config frame — one `readConfig`, two decisions, so §6.4's /// auto-dispatch gate (`commitSubmanifestAdvice`, below) costs no second read. const EmissionDecision = struct { emission: zit.submanifest.Emission, /// The last effective-set shape whose k-cost was settled. Equal shapes need /// no advisory scan; missing/different shapes must be analyzed again. settled_cost_shape: ?[32]u8, /// draft-fossil-submanifests-00 §6.4's `submanifest-auto-retune`: the /// durable project-operator act authorizing subsequent automatic re-tunes. /// Default false, matching the config field's own default — a repository /// that never opted in reads `false` here exactly as it always has. auto_retune: bool, }; /// draft-fossil-submanifests-00 §8.1/§6.3 — the emission decision for THIS /// commit, read from the repository's own config frame. /// /// §8.1: "A writer MUST NOT emit an S card unless the repository's /// enable-submanifests setting -- a project-scope boolean, default false -- is /// true". §6.3: "A writer MUST take k from the pinned setting, MUST NOT derive a /// value per commit ... on receiving a value outside 1..12 it MUST fall back to /// emitting flat manifests rather than substitute a parameter of its own, and /// SHOULD report the fallback to the operator." /// /// The read is folded here rather than inlined at the call site so a config that /// cannot be read is one decision, not two: a repository with no config frame, /// or one whose frame will not decode, is `.off` — the default, the state of /// every repository predating this draft, and the only safe direction (guessing /// "enabled" would mint artifacts §8 calls unreadable). `submanifestEmission` /// makes no output; `reportSubmanifestOutOfRange` preserves the §6.3 /// diagnostic ordering after pre-capture pinned-k validation. fn submanifestEmission( gpa: std.mem.Allocator, log: *logstore.LogStore, ) EmissionDecision { var oc = (log.readConfig(gpa) catch return .{ .emission = .off, .settled_cost_shape = null, .auto_retune = false, }) orelse return .{ .emission = .off, .settled_cost_shape = null, .auto_retune = false, }; defer oc.deinit(gpa); const decision = zit.submanifest.emission(.of(oc.config)); return .{ .emission = decision, .settled_cost_shape = oc.config.submanifest_settled_cost_shape, .auto_retune = oc.config.submanifest_auto_retune, }; } /// draft-fossil-submanifests-00 §6.3's SHOULD-report occurs at the historic /// post-no-op commit phase, even though its pinned-k decision was read earlier. fn reportSubmanifestOutOfRange(streams: cli.Streams, decision: zit.submanifest.Emission) void { if (decision != .out_of_range) return; streams.err.print( "zit commit: {s} ({d}; must be {d}..{d}) — emitting a flat manifest. " ++ "Re-pin with 'zit submanifest retune --k N'.\n", .{ zit.submanifest.out_of_range_report_prefix, decision.out_of_range, artifact.min_k, artifact.max_k, }, ) catch {}; } /// submanifests §8.1 — record the local seen-submanifest indicator, on the /// false->true edge only. Appends a config frame carrying every existing field /// plus the flag (plan E last-frame-wins); a repository already carrying it writes /// nothing. The caller's `log.commit()` makes it durable with the artifacts it /// describes, so the indicator and the trees it is about land in one fsync. 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; }; // 4. Discover the checkout root (B-commit-14) and load the working state. const root = (cli.discoverRoot(arena, io) catch return commitOom(streams)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(io, root_dir, &ck_buf) catch |e| return checkoutFail(streams, "commit", e)) orelse return commitCheckoutMissing(streams); const branch = if (ck.branch.len == 0) "trunk" else ck.branch; const is_root = ck.version.len == 0; // A recorded merge parent (from `zit merge`) turns this into a merge commit: // it emits the two-token P card and clears the record on advance (B-merge-8/ // 9). Empty == no pending merge → the ordinary single-parent path, untouched // (B-merge-9 negative space). A merge is never a root (it requires a prior // version), so `is_merge` implies `!is_root`. const is_merge = ck.merge_parent.len != 0; // A recorded special-merge op (cherrypick/backout, from `zit merge // --cherrypick/--backout`) is NOT a merge: the check-in stays SINGLE-parent // (REV is never a merge parent, B-merge-33) and carries one Q card recording // the provenance (§4.7.14). `special_op` is the q-sign byte glued onto REV's // hash; empty means no pending special op → today's path, untouched. A // special op requires a prior version, so it too implies `!is_root`. At most // one of `is_merge`/`has_special` is ever set (the merge verb refuses to // start a second pending operation). const has_special = ck.special_op.len != 0; const q_sign: ?artifact.QSign = if (!has_special) null else switch (ck.special_op[0]) { '+' => .cherrypick, '-' => .backout, else => unreachable, // `merge` only ever writes '+' or '-' }; // Checkout-record consistency the merge verb maintains: a pending merge or // special op never coexists with an unborn (root) checkout, the two are // mutually exclusive (`merge` refuses to start a second pending operation), // and a Q card is minted exactly for a special op. std.debug.assert(!is_root or (!is_merge and !has_special)); std.debug.assert(!(is_merge and has_special)); std.debug.assert((q_sign != null) == has_special); // repostate §6.2: a branch-root manifest is a CHILD of the branch point, so // `--branch` needs a parent to fork from. The root check-in's default-branch // start is `init`/§6.1's job, not a fork — reject `--branch` on an unborn // checkout before any capture. if (is_root and parsed.branch != null) { streams.err.writeAll( "zit commit: --branch needs a parent check-in; the first commit starts branch 'trunk'\n", ) catch {}; return .failure; } // An empty `--branch` value names no branch (§6.2). Reject it here, with the // other early arg checks, before any store is opened. if (parsed.branch) |bname| { if (bname.len == 0) { streams.err.writeAll("zit commit: --branch name must not be empty\n") catch {}; return .failure; } } // The branch the new check-in lands on: `--branch` overrides, else the // parent's branch (trunk when unset). `compose_branch` is the name whose // §6.2 branch/sym tags this commit emits — always trunk for the root // (establishing it), the explicit `--branch` for a child that switches, and // null for a child that stays on its parent's branch (no tag, inherited). const target_branch = parsed.branch orelse branch; const compose_branch: ?[]const u8 = if (is_root) target_branch else parsed.branch; var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "commit", e); defer set.deinit(); // 5. Open the log read-write (append blobs + manifest, then fsync). var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch return commitLogFail(streams, error.Storage); defer file.close(io); const log_st = file.stat(io) catch return commitLogFail(streams, error.Storage); var fs = storage.FileStorage.init(io, file, log_st.size); // `hash_kind` 0 is a don't-care on a non-empty file: `open` reads the stored // kind from the header. The repo was named SHA3-256 by `init` (B-commit-16). var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| return commitLogFail(streams, e); defer log.deinit(gpa); // 6. One arena owns every card's storage (F slice, arena copies of each // captured hash + filename); `create` reads card fields only, so the CLI // supplies the arena. Content buffers are read on `gpa` and freed per file. var m_arena = std.heap.ArenaAllocator.init(gpa); defer m_arena.deinit(); const ma = m_arena.allocator(); // 6b. Branch prep (repostate §6.2), only when `--branch` starts/switches a // branch. The derived read-only view answers two questions the append // transaction cannot: does NAME already name an open branch (§6.2 forbids // re-creating one), and which of the parent's active sym-* tags must this // commit cancel so the check-in leaves the parent's branch(es). The view // is opened read-only and freed before any append; the sym names are // duped onto `ma` so they outlive it. `--tag`/`--close` need no view. var parent_syms: []const []const u8 = &.{}; if (parsed.branch) |bname| { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "commit", e); defer repo.deinit(gpa); 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 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)), }; } parent_f = pm.?.flattened orelse pm.?.f; // A parent F card whose blob resolves in the log maps its path to that // blob's rid; a card without a resolvable blob is simply absent (its // child blob then stores full — the base hint is optional). for (parent_f) |fc| { const h = fc.hash orelse continue; var bd_buf: [digest_len]u8 = undefined; const bd = hexToDigest(h, &bd_buf) orelse continue; const brid = log.lookup(bd) orelse continue; parent_blob.put(fc.filename, brid) catch return commitOom(streams); } } // 8. Capture the working-tree files and pick the merge policy (B-commit-8/ // 10/11/12). This is the only place the all-files/partial split is // decided; all merge/sort/no-op arithmetic is deferred to composeFCards. var captured: std.ArrayList(artifact.FCard) = .empty; const policy: cli.ComposePolicy = if (parsed.paths.len == 0) .snapshot else .carry_forward; if (parsed.paths.len == 0) { // All-files: capture every managed path (a deletion is simply absent // from the set → not captured → dropped by composeFCards, B-commit-12). for (set.iterate()) |path| { switch (captureFile(gpa, io, ma, root_dir, &log, path, parent_blob.get(path))) { .card => |c| captured.append(ma, c) catch return commitOom(streams), .missing => return commitMissing(streams, path), .not_regular => |is_symlink| return commitNotRegular(streams, path, is_symlink), .io_error => return commitCaptureIo(streams, path), .too_large => |size| return commitTooLarge(streams, path, size), .oom => return commitOom(streams), } } } else { // Partial: capture only the named managed files (B-commit-11). The // unnamed parent files are carried forward by composeFCards, not here. const cwd_abs = (cli.currentPathAlloc(arena, io) catch return commitOom(streams)) orelse return commitLogFail(streams, error.Storage); for (parsed.paths) |arg| { const key = switch (cli.repoRelative(arena, root.abs, cwd_abs, arg) catch return commitOom(streams)) { .key => |k| k, .root, .outside => return commitNotTracked(streams, arg), }; if (!set.contains(key)) return commitNotTracked(streams, key); switch (captureFile(gpa, io, ma, root_dir, &log, key, parent_blob.get(key))) { .card => |c| captured.append(ma, c) catch return commitOom(streams), .missing => return commitMissing(streams, key), .not_regular => |is_symlink| return commitNotRegular(streams, key, is_symlink), .io_error => return commitCaptureIo(streams, key), .too_large => |size| return commitTooLarge(streams, key, size), .oom => return commitOom(streams), } } } // 8b. Stamp the pending rename (§4.7.6): if `zit mv` recorded a rename and // NEW was captured this commit, mark that card with OLD as the fourth // (old-name) token plus an explicit permission token — the grammar keeps // old-name in the fourth field only when a perm token precedes it, and // §4.7.6 SHOULD-w for a regular file (an executable keeps its `x`). The // card is captured under NEW, so composeFCards sorts it under NEW (§4.6) // and the perm/old_name fields ride through the sort/no-op logic // untouched. `carried` gates the one-shot clear (step 12b): a partial // commit that does not name NEW leaves the record pending (B-mv-8). OLD // is duped onto `ma` so the card never aliases the record freed at clear. var carried = false; if (set.pendingRename()) |rn| { for (captured.items) |*c| { if (std.mem.eql(u8, c.filename, rn.new)) { c.old_name = ma.dupe(u8, rn.old) catch return commitOom(streams); c.perm = c.perm orelse "w"; carried = true; break; } } } // Managed-set agreement: every captured card names a path in the managed set // (all-files iterates it; partial gates each arg on `set.contains`), so the // manifest assembles only over tracked files. if (std.debug.runtime_safety) { for (captured.items) |c| std.debug.assert(set.contains(c.filename)); } // 9. Compose the F-set and map the no-op verdict (B-commit-9). Guarded // before any durable write — nothing is committed on a no-op. A rename // always makes NEW differ from the parent's names, so it is never a no-op. const composed = cli.composeFCards(ma, .{ .parent = parent_f, .captured = captured.items, .policy = policy, }) 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 tree_r: [32]u8 = undefined; var tree_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); // §5.5: "For a tree manifest, 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." // For a freshly constructed tree that enumeration is `composed.f` in // order — the very list the construction partitioned. // // 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 = .{}; 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 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); } tree_r_card = .{ .checksum = agg.final(&tree_r) }; } } // 10. Assemble the manifest and serialize it (B-commit-17). The library owns // the Z self-check, card order, escaping, and framing. Root establishes // `trunk` via two propagating T cards (B-commit-13); a child carries one // P card and no T card (inherits trunk by propagation). var ts_buf: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(amb.now_epoch, &ts_buf); // Control tags (repostate §6.2 branch creation + artifact §4.7 close). // The root establishes trunk (two propagating tags — `--branch` is rejected // on the root); a child emits branch/sym/cancel tags only when `--branch` // switches it, plus `+closed` on `--close`. In the common no-flag child case // this is an empty slice, preserving today's bytes exactly. (`--tag` is // deferred behind the propagating-vs-singleton ambiguity, so it is absent.) const tags = cli.composeCommitTags(ma, .{ .branch = compose_branch, .close = parsed.close, .parent_syms = parent_syms, }) catch return commitOom(streams); // A recorded merge extends the single primary parent to a two-token P card // (B-merge-8): `P <primary> <merge-parent>`, primary ancestor first (§4.7.13 // / repostate §3.2 first-listed-is-primary), SP-joined so `appendPCard` // renders both tokens verbatim. The non-merge case keeps `count == 1` and // `parents == ck.version`, so today's manifest bytes are byte-identical. const parents_field: []const u8 = if (is_merge) std.mem.concat(ma, u8, &.{ ck.version, " ", ck.merge_parent }) catch return commitOom(streams) else ck.version; const child_parents = [_]artifact.PCard{.{ .parents = parents_field, .count = if (is_merge) 2 else 1, }}; // A recorded special-merge op mints exactly one Q card (§4.7.14, B-merge-33): // `Q +<rev>` (cherrypick) or `Q -<rev>` (backout), the sign glued onto the // target hash, baseline token OMITTED — the omitted-baseline default IS the // primary parent the merge used, so emitting it would be redundant. The // target borrows `ck.special_op` (which outlives `create`, exactly as the P // card borrows `ck.version`). The non-special case keeps an empty `q` slice, // so today's manifest bytes are byte-identical (B-merge-31). var q_cards: [1]artifact.QCard = undefined; const q_slice: []const artifact.QCard = if (q_sign) |s| blk: { q_cards[0] = .{ .sign = s, .target = ck.special_op[1..], .baseline = null }; break :blk &q_cards; } else &.{}; // Checkout-record agreement: a child's parent version resolved to a stored // manifest (its rid feeds the delta base and the P card); only a root has none. std.debug.assert(is_root or parent_manifest_rid != null); var m: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = message }, .d = .{ .timestamp = ts }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = tree_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) composed.f else &.{}, .j = &.{}, .m = &.{}, .p = if (is_root) &.{} else &child_parents, .q = q_slice, .s = if (tree) |t| t.s else &.{}, .t = tags, }; defer m.deinit(); // frees the empty arena_state; card storage is m_arena's const bytes = artifact.create(&m, gpa) catch return commitOom(streams); defer gpa.free(bytes); // Correctness self-check (safety builds only): the emitted bytes round-trip // to a manifest. A failure means the CLI mis-populated a card — a bug caught // here, never shipped. if (std.debug.runtime_safety) { var check = artifact.parse(gpa, bytes) catch |e| std.debug.panic("commit produced an unparseable manifest: {s}", .{@errorName(e)}); defer check.deinit(); std.debug.assert(check.kind == .manifest); } // 11. Store the manifest and durably commit the log (B-commit-15 order). The // frame is keyed by the binary digest; the reported name is the 64-hex. var mdigest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(bytes, &mdigest, .{}); var mname_buf: [64]u8 = undefined; const manifest_name = artifact.artifactName(.sha3_256, bytes, &mname_buf); // §4.1/§7.2 — store the manifest, delta-encoded against the primary parent // manifest when materially smaller (else a full §4.1 container). The // reported name is the 64-hex of the UNCOMPRESSED manifest, so // delta/compression is address-transparent. _ = log.putArtifact(&mdigest, bytes, parent_manifest_rid) catch |e| return commitLogFail(streams, e); log.commit() catch |e| return commitLogFail(streams, e); // 12a. Clear the pending rename now that the check-in carrying it is durable // (B-mv-8, one-shot: consumed only by the commit that named NEW). Order // is log-durable → managed-clear (fsync) → checkout-advance, so a crash // between stages leaves a recoverable tree (B-commit-15 discipline). The // no-rename common case skips this entirely, so today's bytes and I/O // are unperturbed (B-mv-9). if (carried) { set.clearRename(); set.write(io, root_dir) catch |e| return managedFail(streams, "commit", e); } // 12. Advance the working state — only after the log is durable. The single // non-atomic seam (B-commit-15): a failure here leaves the check-in in // history but the working version un-advanced; a re-run re-parents from // the old version (the durable log is never corrupted). // The pending merge OR special-merge op (if any) is consumed by this // check-in, so the advanced checkout clears BOTH (`merge_parent = ""`, // `special_op = ""`, B-merge-9/34 one-shot clear). checkout.write(io, root_dir, .{ .version = manifest_name, .branch = target_branch, .merge_parent = "", .special_op = "", .manifest_flags = "" }) 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 {}; } // 14. submanifests §6.4 post-commit drift advisory — strictly after the // check-in line above, never before it and never interleaved with the // commit itself: `commitSubmanifestAdvice` returns void, so it is // structurally incapable of changing this verb's exit code. commitSubmanifestAdvice( gpa, io, root_dir, streams, composed.f, emission_k, emission_decision.settled_cost_shape, emission_decision.auto_retune, parsed.no_submanifest_advice, ); return .ok; } /// Renders `zit commit: out of memory` and returns `.failure` (B-commit-15). fn commitOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit commit: out of memory\n") catch {}; return .failure; } /// draft-fossil-submanifests-00 §6.4's post-commit drift advisory: a re-tune /// "MUST be manual and operator-initiated unless automatic re-tuning is /// authorized below", and "A writer MAY re-tune automatically only while the /// repository's submanifest-auto-retune setting -- a project-scope boolean, /// default false -- is true ... setting it true is the durable /// project-operator act authorizing subsequent automatic re-tunes." With /// `auto_retune` false — the config field's own default, so every repository /// predating this slice reads it as such — the two arms below are /// byte-identical to the manual-only path: the pin moves only after an /// explicit 'y' typed at the prompt, never automatically. /// /// Called from `runCommit` exactly once, strictly after the check-in is /// durably recorded (never before, never interleaved with it) — `void` return /// makes it structurally impossible for anything here to change the commit's /// own exit code. Any failure along the way (allocator failure, a failed prompt /// read, a failed write) simply stops and prints nothing further: the commit /// already succeeded, and a broken advisory is not worth confusing that success. /// /// `pinned_k` is `runCommit`'s own `emission_k` — the same `.treeK()`-derived /// "enabled AND pinned-in-range" value step 9c already computed for the tree /// build, so a repository that never turned submanifests on (`pinned_k == /// null`) reaches here and returns immediately, at zero added cost: no config /// read, no `driftCheck` scan, no output (requirement 1). `no_advice` is /// `--no-submanifest-advice`; when set, step 3 onward (the `driftCheck` scan /// itself included) never runs (requirement 2), regardless of `auto_retune` — /// an explicit per-commit "say nothing" wins over the durable opt-in. /// `auto_retune` is `runCommit`'s own `emission_decision.auto_retune`, read /// from the same config frame `pinned_k` was derived from. fn commitSubmanifestAdvice( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, entries: []const artifact.FCard, pinned_k: ?u4, settled_cost_shape: ?[32]u8, auto_retune: bool, no_advice: bool, ) void { if (no_advice) return; const k = pinned_k orelse return; // `entries` is `runCommit`'s exact canonical final F-set: §6.4 advice runs // after durability, but it must not reopen/flatten the checkout merely to // rediscover the same §6.2 tree input. const shape = submanifestCostShapeCheck(entries, settled_cost_shape); if (shape.settled) return; const drift = (zit.submanifest.driftCheck(gpa, entries, k, .sha3_256) catch return) orelse { _ = submanifestWrite( gpa, io, root_dir, null, null, replaceSettledCostShape(shape.digest), streams, ); return; }; // §6.4's advisory line — exact wording, one line, stdout. Printed // regardless of `auto_retune`: §6.4's automatic path still advises, it // just skips the prompt that would otherwise gate the write. streams.out.print( "submanifest-k has drifted from {d} to {d} ({d:.1}% read-cost improvement)\n", .{ k, drift.recommended.k, drift.margin_pct }, ) catch return; if (auto_retune) { if (!submanifestWrite( gpa, io, root_dir, null, drift.recommended.k, replaceSettledCostShape(shape.digest), streams, )) return; streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {}; streams.out.writeAll( "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++ "leaf, once. No history is rewritten and no reader behaves differently.\n", ) catch {}; return; } if (!promptRetune(streams)) return; if (!submanifestWrite( gpa, io, root_dir, null, drift.recommended.k, replaceSettledCostShape(shape.digest), streams, )) return; streams.out.print("submanifest-k: {d} -> {d}\n", .{ k, drift.recommended.k }) catch {}; streams.out.writeAll( "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++ "leaf, once. No history is rewritten and no reader behaves differently.\n", ) catch {}; } const SubmanifestCostShapeCheck = struct { digest: [32]u8, settled: bool, }; /// Internal cache decision only: §6.4 still governs advisory timing and output. /// It needs only the already-composed F-set, never a repository read. fn submanifestCostShapeCheck( entries: []const artifact.FCard, settled_cost_shape: ?[32]u8, ) SubmanifestCostShapeCheck { var digest: [32]u8 = undefined; const current = zit.submanifest.costShapeDigest(entries, &digest); return .{ .digest = digest, .settled = if (settled_cost_shape) |cached| std.mem.eql(u8, cached[0..], current) else false, }; } test "commit submanifest advice §6.4: settled equal shape needs no repository read" { const entries = [_]artifact.FCard{ .{ .filename = "a", .hash = null, .perm = null, .old_name = null, .mode = .regular }, .{ .filename = "b", .hash = null, .perm = null, .old_name = null, .mode = .regular }, }; const initial = submanifestCostShapeCheck(&entries, null); const cached = submanifestCostShapeCheck(&entries, initial.digest); try std.testing.expect(cached.settled); } /// The §6.4 drift-advisory confirm prompt: `retune now? [y/N] ` on stderr /// (the same stream `readPasswordNoEcho` prompts on, `cmd/sync.zig`), one line /// read from stdin, true only when its first byte is `y`/`Y`. /// /// `tcgetattr(STDIN_FILENO)` success/failure is the sole interactivity signal, /// checked BEFORE printing anything — matching `readPasswordNoEcho`'s own /// precedent for "is this interactive" rather than inventing a second one for /// this single call site. On a non-terminal stdin (every automated gate) this /// returns false without printing the prompt or attempting a read, which is /// what makes the non-interactive/advisory-only path exercised by ordinary /// commit tests for free. /// /// Not exercised by the automated gates (they never run against a real tty) /// — this is the interactive path, same honesty `readPasswordNoEcho`'s own /// doc comment states about itself. fn promptRetune(streams: cli.Streams) bool { const fd = std.posix.STDIN_FILENO; _ = std.posix.tcgetattr(fd) catch return false; streams.err.writeAll("retune now? [y/N] ") catch return false; streams.err.flush() catch return false; var buf: [8]u8 = undefined; const n = std.posix.read(fd, &buf) catch return false; if (n == 0) return false; return buf[0] == 'y' or buf[0] == 'Y'; } /// The checkout record vanished between discovery and read (B-commit-15). fn commitCheckoutMissing(s: cli.Streams) cli.Exit { s.err.writeAll("zit commit: cannot read checkout state: .zit-checkout is missing\n") catch {}; return .failure; } /// Renders a B-commit-15 log open/put/commit failure. Exhaustive over the widest /// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError` /// coerce into it, so one mapper serves all three. fn commitLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit { switch (err) { error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {}, error.Storage => s.err.writeAll( "zit commit: cannot write the check-in: input/output error\n", ) catch {}, error.Corrupt => s.err.writeAll("zit commit: the repository is corrupt\n") catch {}, } return .failure; } /// Renders a B-commit-15 parent-manifest content failure. A `Corrupt` frame is /// the parent-corrupt case; exhaustive over `logstore.ContentError`. fn commitContentFail(s: cli.Streams, err: logstore.ContentError) cli.Exit { switch (err) { error.OutOfMemory => s.err.writeAll("zit commit: out of memory\n") catch {}, error.Storage => s.err.writeAll( "zit commit: cannot write the check-in: input/output error\n", ) catch {}, error.Corrupt => s.err.writeAll("zit commit: the parent check-in is corrupt\n") catch {}, } return .failure; } /// The parent check-in named by the checkout could not be found or parsed /// (B-commit-15). A missing parent frame or a malformed manifest is corruption. /// /// `why` names WHICH step failed. Every one of these used to print the same /// sentence, so a name this build could not decode, a parent absent from the /// log, and genuine damage were indistinguishable at the CLI -- which is how /// `zit verify` could report a store clean while `commit` called its parent /// corrupt, with no way to tell from the outside which one was wrong (#24). fn commitParentCorrupt(s: cli.Streams, why: []const u8) cli.Exit { s.err.print("zit commit: the parent check-in is corrupt ({s})\n", .{why}) catch {}; return .failure; } /// The log committed but the working state could not advance (B-commit-15) — the /// single non-atomic seam, stated honestly. fn commitCheckoutWriteFail(s: cli.Streams) cli.Exit { s.err.writeAll( "zit commit: the check-in was recorded but the working state could not be updated: input/output error\n", ) catch {}; return .failure; } /// A managed/selected file is absent from the working tree (B-commit-10). Nothing /// is written — the failure is raised before `logstore.commit`. fn commitMissing(s: cli.Streams, path: []const u8) cli.Exit { s.err.print( "zit commit: cannot commit '{s}': missing from the working tree (use 'zit rm' to stop tracking it)\n", .{path}, ) catch {}; return .failure; } /// A managed/selected file is present but not a regular file (B-commit-10). /// A tracked SYMLINK is the wedge blocker B3 left in the field (a pre-fix `add /// .` registered it, and every later commit refuses): 0.1.0 does not version /// symlinks, so the only way forward is to stop tracking it — the message names /// that recovery instead of leaving it to be guessed. A fifo/socket keeps the /// bare refusal: no recovery is being recommended for content zit never claimed. fn commitNotRegular(s: cli.Streams, path: []const u8, is_symlink: bool) cli.Exit { if (is_symlink) { s.err.print( "zit commit: cannot commit '{s}': " ++ symlink_unversioned ++ "; run 'zit rm {s}' to stop tracking it\n", .{ path, path }, ) catch {}; return .failure; } s.err.print("zit commit: cannot commit '{s}': not a regular file\n", .{path}) catch {}; return .failure; } /// A managed/selected file could not be read or stored (B-commit-15). Raised /// before `logstore.commit`, so nothing durable is written. fn commitCaptureIo(s: cli.Streams, path: []const u8) cli.Exit { s.err.print("zit commit: cannot commit '{s}': input/output error\n", .{path}) catch {}; return .failure; } /// A tracked file too large for one artifact frame. States the actual size and /// the limit, because the operator's next question is "by how much" — and says /// the limit is the repository format's, so it does not read as a setting they /// should go looking for. fn commitTooLarge(s: cli.Streams, path: []const u8, size: u64) cli.Exit { s.err.print( "zit commit: cannot commit '{s}': {d} bytes exceeds the {d}-byte limit on one file\n", .{ path, size, main.max_tracked_file_bytes }, ) catch {}; s.err.print("zit commit: that limit is the repository format's artifact size, not a setting\n", .{}) catch {}; return .failure; } /// A named path in a partial commit is not tracked (B-commit-11). Educating: a /// file must be `zit add`ed before it can be committed. fn commitNotTracked(s: cli.Streams, path: []const u8) cli.Exit { s.err.print("zit commit: '{s}' is not tracked (use 'zit add' first)\n", .{path}) catch {}; return .failure; } pub fn runAmend( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { // 1. Parse (B-amend-1). Usage errors → exit 2; `--help` wins (B-amend-13). const parsed = switch (cli.parseAmendArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "amend", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "amend", .extra_argument, tok), .repeated_option => |tok| return cli.usageError(streams, "amend", .repeated_option, tok), .empty_value => |tok| return cli.usageError(streams, "amend", .empty_value, tok), .bad_date => |tok| return cli.usageError(streams, "amend", .bad_date, tok), .no_override => return cli.usageError(streams, "amend", .no_override, ""), }; if (parsed.help) { streams.out.writeAll(amend_help) catch {}; return .ok; } // NAME is required (B-amend-14): a missing positional is a usage error, before // any repository access. const name = parsed.name orelse return cli.usageError(streams, "amend", .no_name, ""); // 2. Open the repository read-only for resolution (B-amend-15: the repository // check precedes resolution — `openRepo` renders not-a-zit-repository and // exits 1). Full derive: `resolve`/`tagsOf` read the tag tables. var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "amend", amb.repo_override, false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); // 3. Resolve NAME to a check-in and capture everything the mint needs — the // target's 64-hex hash and, for `--branch`, the parent syms to cancel // (repo-state §6.2) — all duped onto `arena` so they outlive the read view, // which is closed before the rw append (commit's durability pattern). var target_hash: []const u8 = undefined; var parent_syms: []const []const u8 = &.{}; { defer repo.deinit(gpa); const rid = repo.resolve(name, .checkin) catch |e| switch (e) { // B-amend-9: an ambiguous hash prefix (repo-state §8.3). error.Ambiguous => { streams.err.print("zit amend: name '{s}' is ambiguous\n", .{name}) catch {}; return .failure; }, error.OutOfMemory => return stateOom(streams, "amend"), error.NotFound => { // B-amend-10: a NAME that names a stored non-check-in artifact (a // control artifact, say) is refused BY its actual type — T cards // target check-ins. Anything else is the B-amend-9 not-found. if (amendArtifactType(gpa, &repo, name)) |type_name| { streams.err.print("zit amend: '{s}' is a {s}, not a check-in\n", .{ name, type_name }) catch {}; } else { streams.err.print("zit amend: check-in '{s}' not found\n", .{name}) catch {}; } return .failure; }, }; target_hash = arena.dupe(u8, repo.hashOf(rid)) catch return amendOom(streams); // The amendment targets a resolved check-in by its full §3.6 artifact id. std.debug.assert(artifact.validateName(target_hash)); // repo-state §6.2: a `--branch` move cancels the target's inherited // (propagating) sym-* tags, minus the branch's own re-established sym. The // surfaced names are duped onto `arena` (the view's interned names die at // `repo.deinit`). if (parsed.branch) |bname| { const want_sym = std.mem.concat(arena, u8, &.{ sym_prefix, bname }) catch return amendOom(streams); const entries = repo.state().tagsOf(gpa, rid) catch return amendOom(streams); defer gpa.free(entries); parent_syms = selectInheritedSyms(arena, entries, want_sym) catch return amendOom(streams); } } // 4. Compose the one control artifact's T run (B-amend-1/4), each card // retargeted at the check-in (artifact §5.4). At least one override is // present (the parser's `no_override` guard), so the run is non-empty. const tags = cli.composeAmendTags(arena, target_hash, .{ .comment = parsed.comment, .user = parsed.user, .date = parsed.date, .branch = parsed.branch, .parent_syms = parent_syms, }) catch return amendOom(streams); // 5-6. Assemble, serialize, and durably store the one control artifact. var cname_buf: [64]u8 = undefined; const control_name = mintControlArtifact(gpa, io, root_dir, amb, tags, &cname_buf) catch |e| return amendLogFail(streams, e); // 7. Success (B-amend-1/16): the one confirmation line to stdout, the full // control-artifact hash as a fixed-position token (mirrors commit's // verb-prefixed line). Every diagnostic already went to stderr. streams.out.print("amend {s}\n", .{control_name}) catch {}; return .ok; } /// Renders `zit amend: out of memory` and returns `.failure`. fn amendOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit amend: out of memory\n") catch {}; return .failure; } /// Renders a B-amend log open/put/commit failure. Exhaustive over the widest /// closed set (`logstore.OpenError`); the narrower `PutError`/`CommitError` /// coerce into it, so one mapper serves all three. fn amendLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit { switch (err) { error.OutOfMemory => s.err.writeAll("zit amend: out of memory\n") catch {}, error.Storage => s.err.writeAll("zit amend: cannot write the amendment: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit amend: the repository is corrupt\n") catch {}, } return .failure; } /// The external type name of the stored artifact named `name` when it is NOT a /// check-in (B-amend-10), or null when `name` names no stored artifact, is /// unparseable, or IS a check-in manifest (in which case the not-found diagnostic /// stands — `resolve` already ruled it out as a crosslinked check-in). Reads the /// raw artifact through the repo's content channel and classifies it; the /// returned name is a static string, valid after the parse is freed. fn amendArtifactType(gpa: std.mem.Allocator, repo: *Repository, name: []const u8) ?[]const u8 { const bytes = (repo.blob(gpa, name) catch return null) orelse return null; defer gpa.free(bytes); var m = artifact.parse(gpa, bytes) catch return null; defer m.deinit(); if (m.kind == .manifest) return null; // a manifest IS a check-in; let not-found stand return m.kind.externalName(); } /// Captures a mapper's stderr into a fixed buffer and returns (exit, bytes). fn commitMapCapture(comptime run_map: anytype, args: anytype) struct { code: cli.Exit, err: []const u8 } { const S = struct { var err_buf: [256]u8 = undefined; }; var w = Io.Writer.fixed(&S.err_buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; const code = @call(.auto, run_map, .{s} ++ args); return .{ .code = code, .err = w.buffered() }; } // Same guard as the `zit update` mapper: `Corrupt` alleges damaged storage, // `StateRecordInvalid` does not. `zit merge` maps most read-path errors onto // one corruption line, so these two have to be pinned apart explicitly or they // drift back into that group. test "merge: the two read-path failures do not share a diagnostic" { // `commitMapCapture` renders into one static buffer, so each message is // copied out before the next render overwrites it. var a: [256]u8 = undefined; var b: [256]u8 = undefined; const corrupt = mergeFailText(&a, error.Corrupt); const state = mergeFailText(&b, error.StateRecordInvalid); try std.testing.expect(!std.mem.eql(u8, corrupt, state)); try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null); try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null); } /// Render one `ApplyError` through `mergeApplyFail` into `buf`. fn mergeFailText(buf: []u8, err: update.ApplyError) []const u8 { const rendered = commitMapCapture(mergeApplyFail, .{err}).err; // `buf` is the caller's, `rendered` borrows `commitMapCapture`'s static // buffer: the fit is a contract of this helper, not a coincidence of the // two sizes agreeing today. std.debug.assert(rendered.len <= buf.len); @memcpy(buf[0..rendered.len], rendered); return buf[0..rendered.len]; } /// Render one `ApplyError` through `bisectMoveFail` into `buf`. fn bisectFailText(buf: []u8, err: update.ApplyError) []const u8 { const rendered = commitMapCapture(bisectMoveFail, .{err}).err; std.debug.assert(rendered.len <= buf.len); @memcpy(buf[0..rendered.len], rendered); return buf[0..rendered.len]; } // `zit bisect` moves the working tree through the SAME `update.applyCheckin` // engine as update, open and merge, so it reaches `StateRecordInvalid` on the // same intact-clone artifacts they do. It is the fourth mapper of that one // error set and must make the same distinction they make. test "bisect: the two read-path failures do not share a diagnostic" { var a: [256]u8 = undefined; var b: [256]u8 = undefined; const corrupt = bisectFailText(&a, error.Corrupt); const state = bisectFailText(&b, error.StateRecordInvalid); try std.testing.expect(!std.mem.eql(u8, corrupt, state)); try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null); try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null); } test "commit B-commit-15: checkout-read errors map to the contract messages, exit 1" { const Case = struct { err: checkout.CheckoutError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .want = "zit commit: cannot read checkout state: .zit-checkout is corrupt\n" }, .{ .err = error.Storage, .want = "zit commit: cannot read checkout state: input/output error\n" }, }; for (cases) |c| { const r = commitMapCapture(checkoutFail, .{ "commit", c.err }); try std.testing.expectEqual(cli.Exit.failure, r.code); try std.testing.expectEqualStrings(c.want, r.err); } } test "commit B-commit-15: managed-read errors map to the contract messages, exit 1" { const Case = struct { err: managed.ManagedError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .want = "zit commit: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .want = "zit commit: cannot read tracked files: input/output error\n" }, .{ .err = error.TooLong, .want = "zit commit: cannot read tracked files: a tracked path is too long\n" }, .{ .err = error.TooMany, .want = "zit commit: cannot read tracked files: too many tracked files\n" }, .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" }, }; for (cases) |c| { const r = commitMapCapture(managedFail, .{ "commit", c.err }); try std.testing.expectEqual(cli.Exit.failure, r.code); try std.testing.expectEqualStrings(c.want, r.err); } } test "commit B-commit-15: log open/put/commit errors map to the contract messages, exit 1" { const Case = struct { err: logstore.OpenError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" }, .{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" }, .{ .err = error.Corrupt, .want = "zit commit: the repository is corrupt\n" }, }; for (cases) |c| { const r = commitMapCapture(commitLogFail, .{c.err}); try std.testing.expectEqual(cli.Exit.failure, r.code); try std.testing.expectEqualStrings(c.want, r.err); } } test "commit B-commit-15: parent-content errors map to the contract messages, exit 1" { const Case = struct { err: logstore.ContentError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "zit commit: out of memory\n" }, .{ .err = error.Storage, .want = "zit commit: cannot write the check-in: input/output error\n" }, .{ .err = error.Corrupt, .want = "zit commit: the parent check-in is corrupt\n" }, }; for (cases) |c| { const r = commitMapCapture(commitContentFail, .{c.err}); try std.testing.expectEqual(cli.Exit.failure, r.code); try std.testing.expectEqualStrings(c.want, r.err); } } test "commit B-commit-10/-11/-15: the single-message diagnostics render exactly, exit 1" { try std.testing.expectEqualStrings( "zit commit: cannot read checkout state: .zit-checkout is missing\n", commitMapCapture(commitCheckoutMissing, .{}).err, ); try std.testing.expectEqualStrings( "zit commit: the parent check-in is corrupt (not present in this repository)\n", commitMapCapture(commitParentCorrupt, .{"not present in this repository"}).err, ); try std.testing.expectEqualStrings( "zit commit: the check-in was recorded but the working state could not be updated: input/output error\n", commitMapCapture(commitCheckoutWriteFail, .{}).err, ); try std.testing.expectEqualStrings("zit commit: out of memory\n", commitMapCapture(commitOom, .{}).err); try std.testing.expectEqualStrings( "zit commit: cannot commit 'a.txt': missing from the working tree (use 'zit rm' to stop tracking it)\n", commitMapCapture(commitMissing, .{"a.txt"}).err, ); try std.testing.expectEqualStrings( "zit commit: cannot commit 'link': not a regular file\n", commitMapCapture(commitNotRegular, .{ "link", false }).err, ); // F-B3: a tracked symlink is the one non-regular kind with a stated // recovery, because a pre-fix `add .` is how it got tracked at all. try std.testing.expectEqualStrings( "zit commit: cannot commit 'link': symlink (not versioned in 0.1.0); run 'zit rm link' to stop tracking it\n", commitMapCapture(commitNotRegular, .{ "link", true }).err, ); try std.testing.expectEqualStrings( "zit commit: cannot commit 'x': input/output error\n", commitMapCapture(commitCaptureIo, .{"x"}).err, ); try std.testing.expectEqualStrings( "zit commit: 'new.txt' is not tracked (use 'zit add' first)\n", commitMapCapture(commitNotTracked, .{"new.txt"}).err, ); } // --------------------------------------------------------------------------- // `zit submanifest [show|enable|disable|retune|analyze]` — the sub-manifest // operator surface (draft-fossil-submanifests-00 §6.3, §6.4, §8.1). // // Four actions over two settings. `show` reports them; `enable`/`disable` move // §8.1's emission gate, `enable` pinning §6.3's partition parameter the first // time and printing the consequence warning; `retune` performs §6.4's manual, // forward-only re-pin; `analyze` runs §6.3's selection pass and writes nothing. // // §8.2's provisional-phase rules make enabling an act with real consequences, // so it is opt-in with the warning printed, not a silent toggle and not an // indefinite refusal: the maintainer's compatibility contract says zit 0.2.0 // stays backwards compatible with fossil, and it does — the DEFAULT is off, and // §7.2's transfer gate refuses a pre-2.29 peer every S-bearing artifact rather // than hand it something it cannot read. // --------------------------------------------------------------------------- pub fn runSubmanifest( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const parsed = switch (cli.parseSubmanifestArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "submanifest", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "submanifest", .extra_argument, tok), .bad_k => |tok| { streams.err.print( "zit submanifest: --k wants an integer {d}..{d} (got '{s}')\n", .{ artifact.min_k, artifact.max_k, tok }, ) catch {}; streams.err.writeAll("try 'zit submanifest --help'\n") catch {}; return .usage; }, .missing_value => |tok| { streams.err.print("zit submanifest: {s} wants a path\n", .{tok}) catch {}; streams.err.writeAll("try 'zit submanifest --help'\n") catch {}; return .usage; }, }; if (parsed.help) { streams.out.writeAll(main.submanifest_help) catch {}; return .ok; } const root = (cli.discoverRoot(arena, io) catch return submanifestOom(streams)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); return switch (parsed.action) { .show => submanifestShow(gpa, io, root_dir, streams), .enable => submanifestEnable(gpa, io, root_dir, parsed.k, streams), .disable => submanifestDisable(gpa, io, root_dir, streams), .retune => submanifestRetune(gpa, io, root_dir, parsed.k, streams), .analyze => submanifestAnalyze(gpa, io, root_dir, parsed.names_path, streams), }; } /// Read the repository's config frame. A missing or unreadable frame is a /// repository-level failure here (unlike on the commit path, where the safe /// default is simply "off"): every action below either reports the settings or /// rewrites them, and both would be lies over a config that did not load. fn submanifestConfig( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) ?logstore.OwnedConfig { var file = root_dir.openFile(io, repo_filename, .{}) catch { streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {}; return null; }; defer file.close(io); const st = file.stat(io) catch { streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {}; return null; }; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch { streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {}; return null; }; defer log.deinit(gpa); const oc = log.readConfig(gpa) catch { streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {}; return null; }; if (oc == null) { streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {}; return null; } return oc; } /// An outer null preserves the cached value; an outer non-null writes its inner /// value, including null to conservatively clear an unreadable checkout's cache. /// This is implementation-only state, not an RFC setting. fn replaceSettledCostShape(shape: ?[32]u8) ??[32]u8 { return shape; } /// Read-modify-write of the sub-manifest settings, preserving every unrelated /// config field (plan E last-frame-wins). `settled_cost_shape` is tri-state so only /// disable may preserve its cache; enable and retune replace it even with null. fn submanifestWrite( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, enabled: ?bool, k: ?u8, settled_cost_shape: ??[32]u8, streams: cli.Streams, ) bool { var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch { streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {}; return false; }; defer file.close(io); const st = file.stat(io) catch { streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {}; return false; }; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch { streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {}; return false; }; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch null) orelse { streams.err.writeAll("zit submanifest: the repository carries no configuration\n") catch {}; return false; }; defer oc.deinit(gpa); var next = oc.config; if (enabled) |value| next.enable_submanifests = value; if (k) |value| next.submanifest_k = value; if (settled_cost_shape) |shape| next.submanifest_settled_cost_shape = shape; log.putConfig(next) catch { streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {}; return false; }; log.commit() catch { streams.err.writeAll("zit submanifest: cannot write the repository\n") catch {}; return false; }; return true; } fn submanifestShow( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) cli.Exit { var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure; defer oc.deinit(gpa); const s: zit.submanifest.Settings = .of(oc.config); streams.out.print("enable-submanifests: {s}\n", .{if (s.enabled) "on" else "off"}) catch {}; if (s.pinned()) { streams.out.print("submanifest-k: {d}\n", .{s.k}) catch {}; } else { streams.out.writeAll("submanifest-k: (not pinned)\n") catch {}; } // §6.4's `submanifest-auto-retune` — shown because it is now a PROPAGATED // setting: a clone adopts it from its origin over sync §9.3, so an operator // can arrive at a repository that re-tunes on commit without ever having // set it here. A setting that can reach a repository without the operator's // act has to be visible to the operator. streams.out.print( "submanifest-auto-retune: {s}\n", .{if (oc.config.submanifest_auto_retune) "on" else "off"}, ) catch {}; // §8.1's local indicator, the thing that arms the §7.2 transfer gate even // after the setting is turned back off. Worth showing: it is the durable // half of "this repository is a disposable test fixture". streams.out.print( "seen-submanifest: {s}\n", .{if (oc.config.seen_submanifest) "yes (this repository holds S-bearing artifacts)" else "no"}, ) catch {}; switch (zit.submanifest.emission(s)) { .off => streams.out.writeAll("commits emit: flat manifests\n") catch {}, .out_of_range => |v| streams.out.print( "commits emit: flat manifests — {s} ({d}; must be {d}..{d})\n", .{ zit.submanifest.out_of_range_report_prefix, v, artifact.min_k, artifact.max_k }, ) catch {}, .tree => |v| streams.out.print("commits emit: tree manifests, partitioned at k={d}\n", .{v}) catch {}, } // §6.3's project-scope requirement is not met; say so where an operator will // read it rather than only in a source comment. streams.out.writeAll(submanifest_scope_gap) catch {}; return .ok; } /// §6.3 — "Project scope (configuration propagating to clones) is REQUIRED". /// A clone now adopts these settings over [FossilSync] §9.3's scalar `/config` /// channel, so the shortfall is narrower than it was: what remains is that a /// repository already in existence never re-reads them from a peer. Printed by /// `show` and by `enable`, and kept honest about which half works. const submanifest_scope_gap = "\nscope: a CLONE adopts these settings from its origin (§6.3 project scope, over\n" ++ "the sync §9.3 configuration exchange), so a fresh clone partitions the way this\n" ++ "repository does. An EXISTING repository is not refreshed by a later pull or\n" ++ "sync — change it here and on each peer. Repositories pinned differently still\n" ++ "exchange artifacts correctly (§6.4) — they just stop sharing sub-manifests.\n"; /// The cache key is an effective F-set shape, never a checkout filename list. /// Failure to read the current check-in leaves the setting change valid but /// uncached; the following commit then conservatively scans. fn submanifestSettledShape( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, ) ?[32]u8 { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch return null; defer repo.deinit(gpa); const rid = repo.currentCheckoutRid() orelse return null; var files = (repo.files(gpa, rid) catch return null) orelse return null; defer files.deinit(gpa); var entries: std.ArrayList(artifact.FCard) = .empty; defer entries.deinit(arena); var it = files.iterator(); while (it.next()) |entry| entries.append(arena, entry) catch return null; var digest: [32]u8 = undefined; _ = zit.submanifest.costShapeDigest(entries.items, &digest); return digest; } fn submanifestEnable( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, k_arg: ?u8, streams: cli.Streams, ) cli.Exit { var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure; defer oc.deinit(gpa); const s: zit.submanifest.Settings = .of(oc.config); // §6.3: "The partition parameter k is selected once, when the // enable-submanifests setting is first turned on for a repository, and // pinned". A repository that already has one keeps it — re-pinning is a // re-tune (§6.4), which is a different verb with a different consequence. if (s.pinned() and k_arg != null and k_arg.? != s.k) { streams.err.print( "zit submanifest: k is already pinned to {d}; changing it is a re-tune — " ++ "use 'zit submanifest retune --k {d}'\n", .{ s.k, k_arg.? }, ) catch {}; return .failure; } // §6.3: "The partition parameter k is selected once, when the // enable-submanifests setting ... is first turned on" — and the draft names // the objective it is selected BY, so selecting it is the writer's job. An // explicit --k overrides; an already-pinned repository keeps its value // (re-pinning is a re-tune); otherwise the analysis pass answers, and // §8.1's "MUST NOT enable emission ... with no pinned parameter" is // satisfied by pinning rather than by refusing. var analyzed_over: ?usize = null; const k: u8 = if (k_arg) |v| v else if (s.pinned()) s.k else blk: { const sel = submanifestSelectK(gpa, io, root_dir, streams) orelse return .failure; analyzed_over = sel.names; break :blk sel.k; }; // §6.3: "MUST support every value from 1 to 12 inclusive (the pinned value // MUST lie in that range)". Refuse to PIN an out-of-range value; the // fallback-and-report rule exists for a value that reaches a writer some // other way, not as a licence to store one here. if (!zit.submanifest.inRange(k)) { streams.err.print( "zit submanifest: k must be {d}..{d} (got {d})\n", .{ artifact.min_k, artifact.max_k, k }, ) catch {}; return .failure; } const settled_shape = submanifestSettledShape(gpa, io, root_dir); if (!submanifestWrite( gpa, io, root_dir, true, k, replaceSettledCostShape(settled_shape), streams, )) return .failure; // §8.1/§8.2 — the consequence, stated before the operator's next commit, // not buried in documentation. streams.out.print("enable-submanifests: on (submanifest-k pinned to {d})\n", .{k}) catch {}; if (analyzed_over) |names| { // §6.3: "The selection is advisory rather than canonical -- any pinned // k yields conforming artifacts". Say what it was measured over, so the // operator can tell a selection from a default. streams.out.print( "submanifest-k was selected by the §6.3 analysis over this check-in's {d} names;\n" ++ "'zit submanifest analyze' shows the full table, 'retune --k N' moves the pin.\n", .{names}, ) catch {}; } streams.out.print("\nWARNING: {s}\n", .{zit.sync.submanifest_enable_warning}) catch {}; streams.out.writeAll(submanifest_scope_gap) catch {}; return .ok; } fn submanifestDisable( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) cli.Exit { // §8.1: "Disabling the setting stops the emission of new tree manifests; it // cannot retire existing ones (artifact names are immutable)." The pin is // deliberately left in place — it is the repository's identity for content // sharing, and clearing it would silently make a later re-enable a re-tune. if (!submanifestWrite(gpa, io, root_dir, false, null, null, streams)) return .failure; streams.out.writeAll("enable-submanifests: off\n") catch {}; var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .ok; defer oc.deinit(gpa); if (oc.config.seen_submanifest) { streams.out.writeAll( "note: this repository already holds S-bearing artifacts. Disabling stops new\n" ++ "tree manifests; it cannot retire the existing ones, and peers below Fossil\n" ++ "2.29 are still refused every one of them.\n", ) catch {}; } return .ok; } fn submanifestRetune( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, k_arg: ?u8, streams: cli.Streams, ) cli.Exit { // §6.4: "A re-tune MUST be manual and operator-initiated". The value is // never inferred here — no argument is a usage failure, not a call into the // analysis pass. const k = k_arg orelse { streams.err.writeAll( "zit submanifest: retune needs an explicit value: 'zit submanifest retune --k N'.\n" ++ "A re-tune is always manual; run 'zit submanifest analyze' first if you want the " ++ "analyzed optimum.\n", ) catch {}; return .failure; }; if (!zit.submanifest.inRange(k)) { streams.err.print( "zit submanifest: k must be {d}..{d} (got {d})\n", .{ artifact.min_k, artifact.max_k, k }, ) catch {}; return .failure; } var oc = submanifestConfig(gpa, io, root_dir, streams) orelse return .failure; defer oc.deinit(gpa); const s: zit.submanifest.Settings = .of(oc.config); if (s.k == k) { streams.out.print("submanifest-k: already {d} — nothing to do\n", .{k}) catch {}; return .ok; } const settled_shape = submanifestSettledShape(gpa, io, root_dir); if (!submanifestWrite( gpa, io, root_dir, null, k, replaceSettledCostShape(settled_shape), streams, )) return .failure; streams.out.print("submanifest-k: {d} -> {d}\n", .{ s.k, k }) catch {}; // §6.4: "The first commit after it shares no sub-manifest with its parent's // tree and rewrites every leaf, once ... the every-leaf rewrite lands as a // surprisingly large commit at an arbitrary moment." Said now, so the // moment is not arbitrary. if (s.enabled) { streams.out.writeAll( "note: the next commit shares no sub-manifest with its parent and rewrites every\n" ++ "leaf, once. No history is rewritten and no reader behaves differently.\n", ) catch {}; } return .ok; } /// §6.3's objective at every k in `min_k..max_k`, in order, into `out`; returns /// how many rows were filled. The shared core of `analyze` (which prints the /// whole table) and `enable` (which pins only the winner), so the value the /// operator is shown is by construction the value that gets pinned. fn submanifestCosts( gpa: std.mem.Allocator, entries: []const artifact.FCard, out: *[artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost, ) std.mem.Allocator.Error!usize { var n: usize = 0; var k: u4 = artifact.min_k; while (true) : (k += 1) { out[n] = try zit.submanifest.cost(gpa, entries, k, .sha3_256); n += 1; // Explicit terminator: `k` is a u4, so raising `max_k` to 15 would make // `k += 1` wrap to 0 and spin forever on a `k <= max_k` condition. if (k == artifact.max_k) break; } return n; } /// §6.3's selection: the k minimizing the objective over the repository's /// current name set. This is the writer's choice, not the operator's — the /// draft has the parameter "selected once, when the enable-submanifests setting /// is first turned on", and names the objective it is selected by. Returns null /// after reporting why no value could be selected; the caller then refuses to /// enable rather than substituting one. fn submanifestSelectK( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) ?struct { k: u8, names: usize } { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const entries = submanifestNamesFromCheckout(gpa, arena_state.allocator(), io, root_dir, streams) orelse { streams.err.writeAll( "zit submanifest: no file set to select a partition parameter from — " ++ "pin one explicitly with 'zit submanifest enable --k N'.\n", ) catch {}; return null; }; if (entries.len == 0) { streams.err.writeAll( "zit submanifest: the current check-in holds no files, so there is nothing to\n" ++ "select a partition parameter from — pin one with 'zit submanifest enable --k N'.\n", ) catch {}; return null; } var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined; const n = submanifestCosts(gpa, entries, &costs) catch { _ = submanifestOom(streams); return null; }; const winner = zit.submanifest.best(costs[0..n]) orelse { // Every k leaves this name set flat (§6.2 step 2), so the objective has // no candidate to minimize and there is no analyzed value to pin. Not a // reason to invent one: the set will not stay this small, and §6.3 // leaves the choice to the operator when the pass cannot make it. streams.err.print( "zit submanifest: no k partitions this repository's {d} names into a tree " ++ "(§6.2 step 2),\nso no parameter can be selected — pin one with " ++ "'zit submanifest enable --k N' if you want one anyway.\n", .{entries.len}, ) catch {}; return null; }; return .{ .k = winner.k, .names = entries.len }; } /// The §6.3 analysis pass over a name set: k = 1..12, the objective for each, /// and the §6.5 prediction beside the measured optimum. /// /// Writes nothing. §6.4: "an implementation MAY advise of drift from the /// analyzed optimum but MUST NOT re-tune automatically" — so this reports and /// stops, and moving the pin is the operator's separate `retune` act. fn submanifestAnalyze( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, names_path: ?[]const u8, streams: cli.Streams, ) cli.Exit { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const aa = arena_state.allocator(); const entries = (if (names_path) |p| submanifestNamesFromFile(aa, io, p, streams) else submanifestNamesFromCheckout(gpa, aa, io, root_dir, streams)) orelse { if (names_path == null) { streams.err.writeAll("zit submanifest: analyze a name list instead with '--names PATH'\n") catch {}; } return .failure; }; if (entries.len == 0) { streams.err.writeAll("zit submanifest: the name set is empty — nothing to analyze\n") catch {}; return .failure; } streams.out.print("names: {d}\n", .{entries.len}) catch {}; streams.out.writeAll("\n k leaves depth S-bytes wmean-leaf total flat-F-bytes\n") catch {}; var costs: [artifact.max_k - artifact.min_k + 1]zit.submanifest.Cost = undefined; const n = submanifestCosts(gpa, entries, &costs) catch return submanifestOom(streams); for (costs[0..n]) |c| { if (c.flat) { streams.out.print( "{d:>3} {s:>44} {d:>12}\n", .{ c.k, "(flat: fewer than two groups, §6.2 step 2)", c.flat_bytes }, ) catch {}; } else { streams.out.print( "{d:>3} {d:>6} {d:>5} {d:>8} {d:>10.1} {d:>8.1} {d:>12}\n", .{ c.k, c.leaves, c.heights, c.s_bytes, c.weighted_mean_leaf_bytes, c.total, c.flat_bytes }, ) catch {}; } } 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 {}; 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 current checkout's effective file set as F cards, on `aa`. fn submanifestNamesFromCheckout( gpa: std.mem.Allocator, aa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, streams: cli.Streams, ) ?[]const artifact.FCard { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch { streams.err.writeAll("zit submanifest: cannot read the repository\n") catch {}; return null; }; defer repo.deinit(gpa); // The fact only — each caller states its own remedy, since `analyze` can be // pointed at a name file and `enable` cannot. const rid = repo.currentCheckoutRid() orelse { streams.err.writeAll( "zit submanifest: no check-in is open, so there is no file set to work from\n", ) catch {}; return null; }; var fl = (repo.files(gpa, rid) catch { streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {}; return null; }) orelse { streams.err.writeAll("zit submanifest: cannot read the current check-in\n") catch {}; return null; }; defer fl.deinit(gpa); var list: std.ArrayList(artifact.FCard) = .empty; var it = fl.iterator(); while (it.next()) |f| { list.append(aa, .{ .filename = aa.dupe(u8, f.filename) catch return null, .hash = if (f.hash) |h| aa.dupe(u8, h) catch return null else null, .perm = if (f.perm) |p| aa.dupe(u8, p) catch return null else null, .old_name = null, // §6.2 partitions on the filename only .mode = f.mode, }) catch return null; } return list.items; } /// A newline-separated name list from `path`, as F cards on `aa`. /// /// §6.1: "The predicate reads the filename only: content hash, permission, and /// old-name never influence the partition." So a name list is a complete input /// to the partition; only the BYTE counts need a hash, and those depend on its /// length, not its value — every entry gets a distinct 64-hex placeholder, the /// width the SHA3-256 policy this pass measures under emits. Blank lines are /// skipped; the list must already be in canonical order (§4.5.3), which is what /// a manifest's F run and `fossil ls` both produce. fn submanifestNamesFromFile( aa: std.mem.Allocator, io: std.Io, path: []const u8, streams: cli.Streams, ) ?[]const artifact.FCard { const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, aa, .limited(max_tracked_file_bytes)) catch { streams.err.print("zit submanifest: cannot read '{s}'\n", .{path}) catch {}; return null; }; var list: std.ArrayList(artifact.FCard) = .empty; var it = std.mem.splitScalar(u8, bytes, '\n'); var seq: u64 = 0; while (it.next()) |raw| { const name = std.mem.trimEnd(u8, raw, "\r"); if (name.len == 0) continue; seq += 1; var digest: [32]u8 = undefined; std.mem.writeInt(u64, digest[0..8], seq, .little); @memset(digest[8..], 0); const hex = aa.alloc(u8, 64) catch return null; _ = std.fmt.bufPrint(hex, "{x}", .{&digest}) catch return null; list.append(aa, .{ .filename = name, .hash = hex, .perm = null, .old_name = null, .mode = .regular, }) catch return null; } const items = list.items; // §6.2's precondition: strictly ascending canonical order. Reported rather // than sorted — a caller whose list is out of order is measuring a different // name set than it thinks it is. for (items, 0..) |e, i| { if (i != 0 and !std.mem.lessThan(u8, items[i - 1].filename, e.filename)) { streams.err.print( "zit submanifest: '{s}' is not in canonical order after '{s}' " ++ "(the name list must be sorted, with no duplicates)\n", .{ e.filename, items[i - 1].filename }, ) catch {}; return null; } } return items; } fn submanifestOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit submanifest: out of memory\n") catch {}; return .failure; }