Artifact 0c810df995c1b4086c04321378a96e2a805746dead13d24cac59df65ac3fec97:
- File
src/main.zig
— part of check-in
[f774a1a2a7]
at
2026-07-18 15:17:48
on branch cli
— Add the annotate command: per-line provenance over the version chain
zit annotate PATH — which check-in introduced each line of one tracked file at the current checkout, and when. One canonical spelling (no blame/praise). The engine walks the file's full-ancestry version chain (repostate.ancestorsOf, the same 3.3 walk merge-base/bisect use; per-version content via Repository.fileAt, the one 8 effective-set query), diffs adjacent versions with the one house line-diff core (merge3.matchLines), and pushes each line's origin back to the OLDEST version carrying it. Across a merge, a line in ANY parent side is charged to that parent (primary first, repo-state 3.2), never to the merge; the merge is charged only for lines in no parent version. Output: 10-char hash, ISO date from event mtime, the line verbatim; a binary file refuses exit 1. Reviewed adversarially (merge attribution hand-traced correct). 1673 unit + 352 e2e green. (user: dmestas, size: 477990) [annotate] [blame] [check-ins using]
//! zit — command-line entry point. //! //! The heavy lifting lives in the `zit` library module (src/root.zig and the //! per-RFC / storage modules it re-exports); this file is only the CLI surface. //! `main` wires I/O and flushes; `run` is a pure-ish dispatcher over an //! injected `std.Io` that returns an exit code, so every verb is exercisable //! without a subprocess (design-seams rule). No `std.http` appears here. const std = @import("std"); const Io = std.Io; const File = Io.File; const zit = @import("zit"); const cli = @import("cli.zig"); const merge3 = @import("merge3.zig"); const annotate_mod = @import("annotate.zig"); const extras = @import("extras.zig"); // The store layers and the repository facade are all part of the one `zit` // library module (root.zig re-exports them), so the CLU imports a single // dependency. `Repository` is the deep read-path facade; `zit.repoview` is its // derived-view collaborator, reached directly only by the pack verify test. 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 serve_mod = @import("serve.zig"); // The clone verb's two CLI-layer seams: `transport` is the sole HTTP/IO module // on the client side (sync §4), `sync_driver` the sans-IO round loop that pumps // `sync.Client` to §3.7 convergence over it. `sync.zig` itself stays sans-IO. const transport = @import("transport.zig"); const sync_driver = @import("sync_driver.zig"); const repostate = zit.repostate; const managed = zit.managed; // The artifact-format vocabulary `commit` assembles a check-in from: the // `Manifest`/card structs it populates, `create` (serializes + computes the Z // self-check), `parse` (reads the parent F-set), and `artifactName` (names each // blob and the manifest). Reached through the same `zit` module cli.zig uses, so // the `FCard` values flow between them without a type mismatch. const artifact = zit.artifact; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const arena = init.arena.allocator(); const io = init.io; // `toSlice` yields sentinel-terminated argv; widen to plain `[]const u8` // slices so the verb runner never depends on NUL termination (a slice of // `[:0]const u8` does not coerce to a slice of `[]const u8`). const argv_z = try init.minimal.args.toSlice(arena); const args = try arena.alloc([]const u8, argv_z.len); for (argv_z, args) |src, *dst| dst.* = src; // Streaming (not positional) writers: `Io.File.Writer.init` defaults to // `.positional`, which writes via `pwrite` from an internal `pos` that // starts at 0 — ignoring the OS file offset zit inherited on stdout/stderr. // That silently clobbers `zit … >> logfile` (O_APPEND) and grouped/shared-fd // redirections (`{ echo a; zit …; } > log`), overwriting bytes another // writer already put there. Streaming mode uses plain `write`/`writev`, // honoring the inherited offset and O_APPEND — the pipe-safe, composable // behavior a process's standard streams must have (contracts/rm.md B-rm-14). var out_buf: [4096]u8 = undefined; var out_writer: Io.File.Writer = .initStreaming(.stdout(), io, &out_buf); var err_buf: [4096]u8 = undefined; var err_writer: Io.File.Writer = .initStreaming(.stderr(), io, &err_buf); const streams: cli.Streams = .{ .out = &out_writer.interface, .err = &err_writer.interface }; // The ambient clock/environment values the runners need, read here — the // single place time and environment enter the CLI (design-seams rule) — and // threaded as plain values so every verb runner stays deterministic under // test. `$ZIT_PASSWORD` (sync §6.3) is a no-persistence password source for // `push`, read at this one seam like `$USER`. const amb: cli.Ambient = .{ .now_epoch = commitEpoch(io, init.environ_map), .user_env = init.environ_map.get("USER") orelse init.environ_map.get("LOGNAME"), .push_password = init.environ_map.get("ZIT_PASSWORD"), }; const code = run(gpa, io, arena, args, streams, amb); streams.out.flush() catch {}; streams.err.flush() catch {}; if (code != .ok) std.process.exit(@intFromEnum(code)); } /// Each verb's `--help` block, keyed by `cli.Verb` — the one place a verb is /// paired with its help text. `zit help <verb>` and `zit <verb> --help` both /// read this, so they teach identical text (DX D4). Verbs with no dedicated /// help (`version`, `help`) and the `unknown` catch-all map to `null`; the /// caller then falls back to `top_usage`. Keyed by the enum, so a new verb /// forces a matching entry here at compile time — no help topic can silently /// drop. const verb_help = std.EnumArray(cli.Verb, ?[]const u8).init(.{ .init = init_help, .add = add_help, .rm = rm_help, .mv = mv_help, .status = status_help, .diff = diff_help, .extras = extras_help, .clean = clean_help, .commit = commit_help, .log = log_help, .update = update_help, .merge = merge_help, .revert = revert_help, .undo = undo_help, .redo = redo_help, .leaves = leaves_help, .timeline = timeline_help, .branch = branch_help, .tag = tag_help, .amend = amend_help, .info = info_help, .ls = ls_help, .cat = cat_help, .artifact = artifact_help, .annotate = annotate_help, .bisect = bisect_help, .serve = serve_help, .clone = clone_help, .pull = pull_help, .push = push_help, .sync = sync_help, .remote = remote_help, .pack = pack_help, .version = null, .help = null, .unknown = null, }); /// The `--help` block for a verb named on the command line, or `null` when the /// verb carries no dedicated help (or the name is unknown) — the caller then /// falls back to `top_usage`. Resolves the name through `cli.verbOf` so help /// topics track the dispatched verb set exactly. fn helpFor(name: []const u8) ?[]const u8 { return verb_help.get(cli.verbOf(name)); } /// Dispatches on the verb (a total switch) and returns the process exit code. /// Never errors: every failure is rendered as an educating diagnostic by the /// verb and folded into the returned `Exit`. fn run( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const verb = if (args.len >= 2) args[1] else "help"; switch (cli.verbOf(verb)) { .init => return runInit(gpa, io, arena, args[2..], streams), .add => return runAdd(gpa, io, arena, args[2..], streams), .rm => return runRm(gpa, io, arena, args[2..], streams), .mv => return runMv(gpa, io, arena, args[2..], streams), .status => return runStatus(gpa, io, arena, args[2..], streams), .diff => return runDiff(gpa, io, arena, args[2..], streams), .extras => return runExtras(gpa, io, arena, args[2..], streams), .clean => return runClean(gpa, io, arena, args[2..], streams), .commit => return runCommit(gpa, io, arena, args[2..], streams, amb), .log => return runLog(gpa, io, arena, args[2..], streams), .update => return runUpdate(gpa, io, arena, args[2..], streams), .merge => return runMerge(gpa, io, arena, args[2..], streams), .revert => return runRevert(gpa, io, arena, args[2..], streams), .undo => return runUndo(gpa, io, arena, args[2..], streams), .redo => return runRedo(gpa, io, arena, args[2..], streams), .leaves => return runLeaves(gpa, io, arena, args[2..], streams), .timeline => return runTimeline(gpa, io, arena, args[2..], streams), .branch => return runBranch(gpa, io, arena, args[2..], streams), .tag => return runTag(gpa, io, arena, args[2..], streams), .amend => return runAmend(gpa, io, arena, args[2..], streams, amb), .info => return runInfo(gpa, io, arena, args[2..], streams), .ls => return runLs(gpa, io, arena, args[2..], streams), .cat => return runCat(gpa, io, arena, args[2..], streams), .artifact => return runArtifact(gpa, io, arena, args[2..], streams), .annotate => return runAnnotate(gpa, io, arena, args[2..], streams), .bisect => return runBisect(gpa, io, arena, args[2..], streams), .serve => return runServe(gpa, io, arena, args[2..], streams), .clone => return runClone(gpa, io, arena, args[2..], streams), .pull => return runPull(gpa, io, arena, args[2..], streams), .push => return runPush(gpa, io, arena, args[2..], streams, amb), .sync => return runSync(gpa, io, arena, args[2..], streams, amb), .remote => return runRemote(gpa, io, arena, args[2..], streams), .pack => return runPack(gpa, io, arena, args[2..], streams), .version => { streams.out.writeAll("zit 0.0.0 (targets Fossil RFC rev " ++ zit.rfc_revision ++ ")\n") catch {}; return .ok; }, .help => { // `zit help <verb>` teaches that verb's own help — the same text as // `zit <verb> --help` (DX D4). Bare `zit`, `zit help`, and // `zit --help` list the commands. An unknown or help-less topic // (e.g. `version`) falls back to the command list, which now names // every real verb including `status`, so nothing reads as absent. if (args.len >= 3) { if (helpFor(args[2])) |topic_help| { streams.out.writeAll(topic_help) catch {}; return .ok; } } streams.out.writeAll(top_usage) catch {}; return .ok; }, .unknown => { // A git muscle-memory verb (rebase, checkout, push, …) gets a // lesson that corrects the zit model instead of a bare error, so // the diagnostic teaches (DX D2). Still a usage error: exit 2, // stderr only, nothing on stdout. if (cli.reflexLesson(verb)) |lesson| { streams.err.print("zit: '{s}' is not a zit command. {s}\n", .{ verb, lesson }) catch {}; return .usage; } // A planned-but-unbuilt zit command (e.g. `commit`, which init's // help points the user at) resolves to a promise, not a dead-end: // every verb the shipped help names owns a handler or this stub. if (cli.isPlanned(verb)) { streams.err.print("zit: '{s}' is planned but not implemented yet.\n", .{verb}) catch {}; return .usage; } // Otherwise the generic path: name the unknown command, offer a // near-miss suggestion when one is close (DX D4), then the command // list so discoverability survives a growing verb set. streams.err.print("zit: unknown command '{s}'\n", .{verb}) catch {}; if (cli.didYouMean(verb)) |suggestion| { streams.err.print("did you mean '{s}'?\n", .{suggestion}) catch {}; } streams.err.writeAll("\n") catch {}; streams.err.writeAll(top_usage) catch {}; return .usage; }, } } /// `zit init [-q|--quiet] [--] [directory]` — the thin wiring: parse, resolve /// an absolute target, then drive the existing library sequence (create `.zit`, /// `LogStore.open`/`commit`/`deinit`, `checkout.write`). Filesystem access is /// confined to this function. All-or-nothing: any failure after `.zit` is /// created rolls it back so a failed init never poisons the already-exists /// guard (B-init-9). (contracts/init.md B-init-1..12.) fn runInit( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-init-1). Usage errors → exit 2 (B-init-10). const parsed = switch (cli.parseInitArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "init", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "init", .extra_argument, tok), }; // --help wins over every effect (B-init-11). if (parsed.help) { streams.out.writeAll(init_help) catch {}; return .ok; } // 2. Resolve an absolute target directory and repo path (B-init-2/5/12). // `resolve` normalizes `.`/`..` and, when `dir_arg` is already // absolute, ignores the cwd — yielding a stable, parseable success token. const dir_arg = parsed.directory orelse "."; // Uses the grow-buffer helper (not std's `currentPathAlloc`, which aborts on // a cwd deeper than the platform PATH_MAX). A cwd we cannot name is reported // honestly, never a crash. const cwd = (cli.currentPathAlloc(arena, io) catch |e| return renderStorage(streams, dir_arg, reasonOf(e))) orelse return renderStorage(streams, dir_arg, "cannot determine the current directory"); const abs_dir = std.fs.path.resolve(arena, &.{ cwd, dir_arg }) catch |e| return renderStorage(streams, dir_arg, reasonOf(e)); const abs_zit = std.fs.path.join(arena, &.{ abs_dir, repo_filename }) catch |e| return renderStorage(streams, abs_dir, reasonOf(e)); // 3. Ensure the target directory exists (B-init-5); a file in the way is // "not a directory" (B-init-8). const cwd_dir = std.Io.Dir.cwd(); cwd_dir.createDirPath(io, abs_dir) catch |e| switch (e) { error.NotDir => return renderNotDir(streams, abs_dir), else => return renderStorage(streams, abs_dir, reasonOf(e)), }; // 4. Open a handle to the target dir; all subsequent file ops go through it // so `.zit`/`.zit-checkout` land in the resolved directory. var target = cwd_dir.openDir(io, abs_dir, .{}) catch |e| switch (e) { error.NotDir => return renderNotDir(streams, abs_dir), else => return renderStorage(streams, abs_dir, reasonOf(e)), }; defer target.close(io); // 5. Create `.zit` exclusively — this defines the "already exists" error // out of existence (no separate stat probe, no TOCTOU). Detection is // precisely the presence of `.zit`; an orphan `.zit-checkout` is // irrelevant (B-init-7). var file = target.createFile(io, repo_filename, .{ .read = true, .exclusive = true }) catch |e| switch (e) { error.PathAlreadyExists => return renderAlreadyExists(streams, abs_zit), error.IsDir, error.NotDir => return renderNotDir(streams, abs_zit), else => return renderStorage(streams, abs_dir, reasonOf(e)), }; // 6. Default hash policy (B-init-3): a fresh repo with no SHA-named // artifacts is SHA3-256 (`.sha3` = 2). Threaded opaquely into the header. const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); // 7. Log lifecycle (B-init-2): `open` on the empty file writes the 64-byte // header (zero frames); `commit` fsyncs it (durability before success). // On any failure, close and roll back `.zit` (B-init-9). var fs = storage.FileStorage.init(io, file, 0); var log = logstore.LogStore.open(gpa, fs.storageFile(), hash_kind) catch |e| { file.close(io); target.deleteFile(io, repo_filename) catch {}; return renderStorage(streams, abs_dir, reasonOf(e)); }; // §9.1 — give the repository a stable, unique project code (sync §9.1: // `projectcode = artifact-hash`). Derived deterministically from the // repo's absolute path — reproducible, distinct per repository, and with // no ambient RNG (the determinism seam). Persisted as repo config so // `serve` emits it in the clone reply's `push` card and a clone adopts it. const project_code = zit.sync.deriveProjectCode(&.{abs_zit}); log.putConfig(.{ .hash_policy = hash_kind, .default_branch = "trunk", .project_id = "", .project_code = &project_code, }) catch |e| { log.deinit(gpa); file.close(io); target.deleteFile(io, repo_filename) catch {}; return renderStorage(streams, abs_dir, reasonOf(e)); }; log.commit() catch |e| { log.deinit(gpa); file.close(io); target.deleteFile(io, repo_filename) catch {}; return renderStorage(streams, abs_dir, reasonOf(e)); }; log.deinit(gpa); file.close(io); // 8. Checkout record (B-init-2/4): trunk unborn (`version == ""`). // `checkout.write` is itself atomic, so a partial record is impossible; // on failure roll back `.zit` (B-init-9). checkout.write(io, target, .{ .version = "", .branch = "trunk", .merge_parent = "" }) catch |e| { target.deleteFile(io, repo_filename) catch {}; return renderStorage(streams, abs_dir, reasonOf(e)); }; // 9. Success feedback (B-init-2/6/12): a single plain-ASCII line whose // trailing whitespace-delimited token is the absolute `.zit` path; // `-q`/`--quiet` writes nothing. if (!parsed.quiet) { streams.out.print("Initialized empty zit repository in {s}\n", .{abs_zit}) catch {}; } return .ok; } /// The per-path outcome of classifying one `add` argument. A tagged union so /// the runner renders each case to the correct stream and folds it into the /// exit code, with the managed-set key carried on the two cases that print it. const Outcome = union(enum) { /// Newly inserted; `added <key>` to stdout (`would add` under -n). added: []const u8, /// Already in the set; informational notice to stderr (B-add-3). already_tracked: []const u8, no_such_file, // B-add-4 is_directory, // B-add-5 not_regular, // B-add-5 outside_root, // B-add-6 control_file, // B-add-7 /// A bound was exceeded on insert (TooLong/TooMany) or allocation failed; /// rendered by `addFail` with the offending arg (B-add-17). insert_error: managed.ManagedError, }; /// `zit add [-n] [-q] [--] <path>...` — the thin wiring: parse, discover the /// checkout root, load the managed set, classify/insert each named path in /// argument order (do-as-much-as-possible), persist once atomically, and fold /// the per-path outcomes into one exit code. Touches working-copy state only; /// no log append, no network. (contracts/add.md B-add-1..18.) fn runAdd( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-add-1). --help wins over everything, including a missing path // (B-add-13); an empty path list is the B-add-9 usage error, detected // before any repo discovery or I/O. const parsed = switch (cli.parseAddArgs(gpa, verb_args) catch return addFail(streams, "", error.OutOfMemory)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "add", .unknown_option, tok), }; defer gpa.free(parsed.paths); if (parsed.help) { streams.out.writeAll(add_help) catch {}; return .ok; } if (parsed.paths.len == 0) return cli.usageError(streams, "add", .no_paths, ""); // 2. Discover the checkout root (B-add-8). Absent → not a zit repository. const root = (cli.discoverRoot(arena, io) catch return addFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // Uses the grow-buffer helper (not std's panicking `currentPathAlloc`); a // cwd we cannot name after discovery succeeded is a storage failure. const cwd_abs = (cli.currentPathAlloc(arena, io) catch return addFail(streams, "", error.OutOfMemory)) orelse return addFail(streams, "", error.Storage); // 3. Load the current set (absent file ⇒ empty; B-add-17 on a bad read). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return addFail(streams, "", e); defer set.deinit(); // 4. Process each path in argument order (B-add-2..7/-11/-12/-15). var failed = false; var changed = false; for (parsed.paths) |arg| { const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, arg) catch { failed = true; _ = addFail(streams, arg, error.OutOfMemory); continue; }; switch (outcome) { .added => |key| { changed = true; if (!parsed.quiet) { const verb_word: []const u8 = if (parsed.dry_run) "would add" else "added"; streams.out.print("{s} {s}\n", .{ verb_word, key }) catch {}; } }, .already_tracked => |key| { if (!parsed.quiet) streams.err.print("zit: '{s}' is already tracked\n", .{key}) catch {}; }, .no_such_file => { failed = true; streams.err.print("zit add: '{s}': no such file\n", .{arg}) catch {}; }, .is_directory => { failed = true; streams.err.print( "zit add: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n", .{arg}, ) catch {}; }, .not_regular => { failed = true; streams.err.print("zit add: '{s}' is not a regular file\n", .{arg}) catch {}; }, .outside_root => { failed = true; streams.err.print("zit add: '{s}' is outside the repository\n", .{arg}) catch {}; }, .control_file => { failed = true; streams.err.print( "zit add: '{s}' is a zit control file and cannot be tracked\n", .{arg}, ) catch {}; }, .insert_error => |e| { failed = true; _ = addFail(streams, arg, e); }, } } // 5. Persist once, atomically, only if the set actually changed and this is // not a dry run (B-add-11/-16). A write failure leaves the prior file // wholly intact (ManagedSet.write is write-temp → fsync → rename). if (changed and !parsed.dry_run) { set.write(io, root_dir) catch |e| return addFail(streams, "", e); } // 6. Fold outcomes into one code (B-add-18): valid additions persist even // when a sibling path failed (partial success). return if (failed) .failure else .ok; } /// Classifies one raw `add` argument into an `Outcome` — all of add's /// filesystem access lives here (design-seams rule). Order is chosen so the most /// specific message wins: resolve to a repo-relative key (escape → outside), /// reject control files, stat for existence/kind, then membership/insert. Only /// allocation failure escapes as an error; every other condition is an /// `Outcome`. fn classifyPath( gpa: std.mem.Allocator, io: std.Io, root_abs: []const u8, root_dir: std.Io.Dir, cwd_abs: []const u8, set: *managed.ManagedSet, arg: []const u8, ) error{OutOfMemory}!Outcome { // 1. Normalize to a repo-relative key. An escape is B-add-6; the root dir // itself is B-add-5's directory case (e.g. `zit add .` at the root), not // an escape. const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .outside_root, .root => return .is_directory, .key => |k| k, }; // 2. A key beyond the managed-set bound cannot be tracked (B-add-17 // error.TooLong). Checked here, not via stat: a name-too-long key makes // the OS stat fail with NameTooLong, which would otherwise mask the honest // "path is too long" message as "no such file". Same bound // `ManagedSet.insert` enforces (`managed.max_path_len`). if (key.len > managed.max_path_len) return .{ .insert_error = error.TooLong }; // 3. The three control files are never version-controlled (B-add-7). Checked // before stat because they exist as real files and would otherwise add. if (isControlFile(key)) return .control_file; // 4. Existence and file-kind (B-add-4/-5). Symlinks are not followed so a // symlink is rejected as non-regular, not silently resolved to its target. const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .no_such_file; switch (st.kind) { .directory => return .is_directory, .file => {}, else => return .not_regular, } // 5. Membership then insert (B-add-2/-3). `contains` first keeps a re-add // error-free; `insert`'s false branch is race-defensive idempotency. if (set.contains(key)) return .{ .already_tracked = key }; const inserted = set.insert(key) catch |e| return .{ .insert_error = e }; return if (inserted) .{ .added = key } else .{ .already_tracked = key }; } /// Whether `key` names a repo-internal control entry that is never tracked /// (B-add-7, B-undo-13). Delegates to `extras.isControlName`, the single /// never-tracked predicate shared with `extras` (and later `clean`), so the /// tracked/never-tracked boundary cannot drift. That set is a STRICT SUPERSET /// of add/rm's original four names (`.zit`, `.zit-checkout`, `.zit-managed`, /// `.zit-undo`), additionally refusing the transient artifacts `.zit-cache` and /// `.zit-pack-tmp` — names `zit` creates, never user content (B-extras-5). fn isControlFile(key: []const u8) bool { return extras.isControlName(key); } /// Renders a B-add-17 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. `arg` is used only by `TooLong` /// (the per-path bound); the read/write-context errors carry no path. fn addFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit add: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit add: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit add: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit add: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit add: out of memory\n") catch {}, } return .failure; } // ========================================================================== // `zit rm` — add's mirror over the per-checkout managed-path set. Forgets named // paths (default: disk untouched); `--hard` also deletes the working file after // the set is durably written. Thin consumer: no logic added to any library // module, no `std.http`. (contracts/rm.md B-rm-1..19.) // ========================================================================== /// The per-path outcome of classifying one `rm` argument. A tagged union so the /// runner renders each case to the correct stream and folds it into the exit /// code. `forgotten`/`not_tracked` carry the normalized managed-set key (the /// identity `contains`/`remove` operate on and, per B-rm-15, that `rm` reports); /// the three resolution-time rejects carry no payload and are rendered against /// the raw arg (exactly as `add` renders its rejects). const RmOutcome = union(enum) { /// Was in the set and is now removed; `removed <key>` (`would remove` -n). forgotten: []const u8, /// A resolved key not in the set (B-rm-4 error notice to stderr). not_tracked: []const u8, is_directory, // B-rm-5 (the checkout root itself) outside_root, // B-rm-6 control_file, // B-rm-7 }; /// `zit rm [-n] [--hard] [-q] [--] <path>...` — the thin wiring: parse, discover /// the checkout root, load the managed set, classify/forget each named path in /// argument order (do-as-much-as-possible), persist once atomically, then — /// strictly after a successful write — delete the `--hard` working files. Folds /// the per-path outcomes into one exit code. Touches working-copy state only /// (`.zit-managed`, and under `--hard` the named files); no log append, no /// network. (contracts/rm.md B-rm-1..19.) fn runRm( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-rm-1). --help wins over everything, including a missing path // (B-rm-13); an empty path list is the B-rm-9 usage error, detected // before any repo discovery or I/O (B-rm-9). Unknown option → B-rm-10. const parsed = switch (cli.parseRmArgs(gpa, verb_args) catch return rmFail(streams, "", error.OutOfMemory)) { .ok => |p| p, .unknown_option => |tok| { // A git user reaches for `--force`/`-f`, but `rm` is safe by default // (files are kept on disk) so forcing is meaningless. Teach the model // in one line instead of a bare "unknown option" (DX). Still a usage // error: exit 2, stderr only. if (std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f")) { streams.err.writeAll( "zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n", ) catch {}; return .usage; } return cli.usageError(streams, "rm", .unknown_option, tok); }, }; defer gpa.free(parsed.paths); if (parsed.help) { streams.out.writeAll(rm_help) catch {}; return .ok; } if (parsed.paths.len == 0) return cli.usageError(streams, "rm", .no_paths, ""); // 2. Discover the checkout root (B-rm-8). Absent → not a zit repository. const root = (cli.discoverRoot(arena, io) catch return rmFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // kept open for the --hard unlink pass const cwd_abs = (cli.currentPathAlloc(arena, io) catch return rmFail(streams, "", error.OutOfMemory)) orelse return rmFail(streams, "", error.Storage); // 3. Load the current set (absent file ⇒ empty; B-rm-17 on a bad read). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return rmFail(streams, "", e); defer set.deinit(); // 4. Process each path in argument order (B-rm-2/-4..7/-11/-15). Collect the // forgotten keys (in argument order) rather than reporting them inline: // the `removed`/`would remove` stdout lines are WITHHELD until the atomic // write has succeeded (step 5b), so a persistence failure (B-rm-17) never // claims a removal that did not happen — `zit rm <files> | wc -l` counts // exactly the forgets that were durably written (B-rm-14). The same list // drives the `--hard` unlink pass (B-rm-16: persist before destroy). // Per-path error/notice diagnostics still print inline: they are // independent of the write and belong on stderr regardless of its fate. var failed = false; var changed = false; var forgotten: std.ArrayList([]const u8) = .empty; for (parsed.paths) |arg| { const outcome = classifyRmPath(arena, root.abs, cwd_abs, &set, arg) catch { failed = true; _ = rmFail(streams, arg, error.OutOfMemory); continue; }; switch (outcome) { .forgotten => |key| { changed = true; forgotten.append(arena, key) catch return rmFail(streams, arg, error.OutOfMemory); }, .not_tracked => |key| { failed = true; streams.err.print("zit rm: '{s}' is not tracked\n", .{key}) catch {}; }, .is_directory => { failed = true; streams.err.print( "zit rm: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n", .{arg}, ) catch {}; }, .outside_root => { failed = true; streams.err.print("zit rm: '{s}' is outside the repository\n", .{arg}) catch {}; }, .control_file => { failed = true; streams.err.print( "zit rm: '{s}' is a zit control file and cannot be tracked\n", .{arg}, ) catch {}; }, } } // 5. Persist once, atomically, only if the set changed and this is not a dry // run (B-rm-16). Returning on a write failure guarantees NOTHING below // runs — neither the stdout report (5b) nor the --hard unlink (6): a write // failure claims no removal on stdout and unlinks nothing, leaving every // file both on disk and still tracked (B-rm-16/-17/-14). if (changed and !parsed.dry_run) { set.write(io, root_dir) catch |e| return rmFail(streams, "", e); } // 5b. Report the forgets on stdout — only now that they are durable (or, in a // dry run, never persisted but truthfully previewed). Argument order is // preserved (the collection order). Held until here so a failed write // never prints a `removed` line for a forget that did not survive // (B-rm-2/-11/-14). Under a real `--hard` run the disposition is NOT yet // known — it depends on the per-file unlink outcome — so those lines are // withheld and emitted in step 6, keyed on the actual deletion, so the // `(deleted from disk)` suffix can never claim a deletion that failed // (B-rm-3/-4/-14/-18). A dry run never unlinks, so `-n --hard` previews // `(deleted from disk)` here truthfully (subjunctive; B-rm-11). const defer_report = parsed.hard and !parsed.dry_run; if (!parsed.quiet and !defer_report) { // D1/D6: state the resulting file disposition in the line so the user is // never left guessing whether their working copy survived, while keeping // the leading `removed <key>` / `would remove <key>` tokens stable so the // output stays pipe-parseable and `zit rm <files> | wc -l` still counts // one line per removal (B-rm-2/-11/-14). The verb word tracks `-n`; the // suffix tracks `--hard` (only reachable here as a dry-run preview). const verb_word: []const u8 = if (parsed.dry_run) "would remove" else "removed"; const disposition: []const u8 = if (parsed.hard) "(deleted from disk)" else "(file kept on disk)"; for (forgotten.items) |key| streams.out.print("{s} {s} {s}\n", .{ verb_word, key, disposition }) catch {}; } // 6. --hard: delete each forgotten working file, strictly after the write // succeeded (B-rm-3/-16/-18). An already-absent file is success (the "no // working file" postcondition holds). The stdout `removed <key> (deleted // from disk)` line is emitted HERE, keyed on the REAL outcome: only a // deleted (or already-gone) file prints it (B-rm-3); a file that could not // be unlinked is a failed path, so its diagnostic goes to stderr and // NOTHING about it reaches stdout (B-rm-4/-14/-18) — the disposition suffix // can never lie. The forget itself already stands and is persisted (honest // partial). `-q` suppresses the stdout line but never the error. if (defer_report) { for (forgotten.items) |key| { const deleted = if (root_dir.deleteFile(io, key)) |_| true else |e| switch (e) { error.FileNotFound => true, // already gone ⇒ postcondition holds (B-rm-3) else => blk: { failed = true; streams.err.print( "zit rm: '{s}': removed from tracking but could not delete from disk: {s}\n", .{ key, reasonOf(e) }, ) catch {}; break :blk false; }, }; if (deleted and !parsed.quiet) streams.out.print("removed {s} (deleted from disk)\n", .{key}) catch {}; } } // 7. Fold outcomes into one code (B-rm-19): successful forgets persist even // when a sibling path failed (partial success). return if (failed) .failure else .ok; } /// Classifies one raw `rm` argument into an `RmOutcome`. Filesystem-free (unlike /// `add`'s `classifyPath`): membership in the set decides, not the disk, so a /// managed path whose working file the user already deleted is still forgettable /// (contracts/rm.md Model). Order is chosen so the most specific message wins: /// resolve to a repo-relative key (escape → outside, root → directory), reject /// control files, then membership. Only allocation failure escapes as an error. fn classifyRmPath( gpa: std.mem.Allocator, root_abs: []const u8, cwd_abs: []const u8, set: *managed.ManagedSet, arg: []const u8, ) error{OutOfMemory}!RmOutcome { // 1. Normalize to a repo-relative key. An escape is B-rm-6; the root dir // itself is B-rm-5's directory case (e.g. `zit rm .` at the root). const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .outside_root, .root => return .is_directory, .key => |k| k, }; // 2. The three control files are never version-controlled and must never be // `--hard`-unlinked (B-rm-7). Checked BEFORE membership: a belt-and- // suspenders guard so even a corrupt set holding a control-file key could // not get it deleted. No max_path_len guard is needed (unlike add): an // over-length key is simply absent from the set → falls to not_tracked. if (isControlFile(key)) return .control_file; // 3. Membership then forget (B-rm-2/-4). `remove` returns whether it was // present (managed.zig:114): true ⇒ forgotten, false ⇒ never tracked. It // is a no-op on an absent key, so the membership verdict and the forget // fold into one call — no separate `contains` needed. if (set.remove(key)) return .{ .forgotten = key }; return .{ .not_tracked = key }; } /// Renders a B-rm-17 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. Only `Corrupt`/`Storage`/ /// `OutOfMemory` are reachable from `rm`'s flow (`remove` never errors; `insert` /// is never called), so `TooLong`/`TooMany` exist only for exhaustiveness. fn rmFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit rm: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit rm: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit rm: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit rm: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit rm: out of memory\n") catch {}, } return .failure; } // ========================================================================== // `zit mv OLD NEW` — rename a tracked file. Moves the working file on disk, // swaps the tracked-name entry in the managed set, and RECORDS the rename so // the next `zit commit` emits the §4.7.6 four-token old-name F card. A thin // consumer of the existing managed-set + repo-relative machinery; the one new // mechanism (the recorded rename) is hidden inside managed.zig. // (contracts/mv.md B-mv-1..18.) // ========================================================================== /// The outcome of resolving one `mv` operand to a repo-relative key, or the /// refusal phrase to render against the raw operand (rendered as /// `zit mv: '<arg>' <phrase>`). A directory (the root) and an escape are both /// refusals: mv renames a single tracked file, never a directory or an outside /// path (contracts/mv.md Non-goals). const MvResolved = union(enum) { key: []const u8, refuse: []const u8, }; /// Resolves one raw `mv` operand to a normalized repo-relative key, or a refusal /// phrase. Filesystem-free: only string resolution + the control-file guard. fn mvResolveKey( gpa: std.mem.Allocator, root_abs: []const u8, cwd_abs: []const u8, arg: []const u8, ) error{OutOfMemory}!MvResolved { const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .{ .refuse = "is outside the repository" }, .root => return .{ .refuse = "is a directory; mv renames a single tracked file" }, .key => |k| k, }; if (isControlFile(key)) return .{ .refuse = "is a zit control file and cannot be tracked" }; return .{ .key = key }; } /// Whether `key` names an existing entry directly under `dir` (symlinks are not /// followed, so a dangling symlink still counts as present — mv must not clobber /// it). Any stat failure reads as absent. fn mvOnDisk(io: std.Io, dir: std.Io.Dir, key: []const u8) bool { _ = dir.statFile(io, key, .{ .follow_symlinks = false }) catch return false; return true; } /// `zit mv OLD NEW` — the thin wiring: parse, discover the checkout root, load /// the managed set, resolve+validate both operands, refuse a collision, then /// (all-or-nothing) swap the managed entry + record the rename and persist it /// durably BEFORE moving the working file on disk (B-mv-2 / B-commit-15 order). /// Touches working-copy state only (`.zit-managed` and the renamed file); no log /// append, no network. (contracts/mv.md B-mv-1..18.) fn runMv( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-mv-12/-13/-14). --help wins over everything (B-mv-12). const parsed = switch (cli.parseMvArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "mv", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "mv", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(mv_help) catch {}; return .ok; } // mv takes EXACTLY two operands: a missing one is the exit-2 usage class, // stderr-only with the try-help line (B-mv-14). const old_arg = parsed.old orelse return mvMissingOperand(streams); const new_arg = parsed.new orelse return mvMissingOperand(streams); // 2. Discover the checkout root (B-mv-15) — before any tracked/disk check. const root = (cli.discoverRoot(arena, io) catch return mvFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); const cwd_abs = (cli.currentPathAlloc(arena, io) catch return mvFail(streams, "", error.OutOfMemory)) orelse return mvFail(streams, "", error.Storage); // 3. Load the managed set (absent ⇒ empty; B-mv-16 on a bad read, before any // move). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return mvFail(streams, "", e); defer set.deinit(); // 4. Resolve both operands to normalized repo-relative keys. const old_key = switch (mvResolveKey(arena, root.abs, cwd_abs, old_arg) catch return mvFail(streams, "", error.OutOfMemory)) { .key => |k| k, .refuse => |ph| return mvRefuse(streams, old_arg, ph), }; const new_key = switch (mvResolveKey(arena, root.abs, cwd_abs, new_arg) catch return mvFail(streams, "", error.OutOfMemory)) { .key => |k| k, .refuse => |ph| return mvRefuse(streams, new_arg, ph), }; // 5. Reject any illegal rename BEFORE mutating (B-mv-3/-4/-5 plus the // source-exists guard). The gauntlet leaves stderr set and returns the // exit for the first violation; null means the move cannot fail on an // absent source. if (mvCheckLegal(io, root_dir, &set, old_key, new_key, streams)) |exit| return exit; // 6. Commit: swap + record + persist durably, THEN move the file (B-mv-2). return mvCommit(io, root_dir, &set, old_key, new_key, new_arg, streams); } /// The pre-mutation legality gauntlet for a rename. Returns the `.failure` exit /// (diagnostic already on stderr) for the first violated condition, or null if /// the rename is legal. Order fixes which message wins when several hold: /// OLD must be tracked (B-mv-3); NEW must not be tracked (B-mv-4) — also catches /// NEW==OLD, which is tracked; NEW must not exist on disk (B-mv-5), also catching /// NEW==OLD on disk; OLD must exist on disk, so the caller's move cannot fail on /// an absent source (defines the disk-rename failure out of the mutation path). fn mvCheckLegal( io: std.Io, root_dir: std.Io.Dir, set: *const managed.ManagedSet, old_key: []const u8, new_key: []const u8, streams: cli.Streams, ) ?cli.Exit { if (!set.contains(old_key)) { streams.err.print("zit mv: '{s}' is not tracked\n", .{old_key}) catch {}; return .failure; } if (set.contains(new_key)) { streams.err.print("zit mv: '{s}' is already tracked\n", .{new_key}) catch {}; return .failure; } if (mvOnDisk(io, root_dir, new_key)) { streams.err.print("zit mv: '{s}' already exists on disk\n", .{new_key}) catch {}; return .failure; } if (!mvOnDisk(io, root_dir, old_key)) { streams.err.print("zit mv: '{s}' is tracked but missing from the working tree\n", .{old_key}) catch {}; return .failure; } return null; } /// Performs the rename once `mvCheckLegal` has passed. Swaps the entry in memory /// (`remove` is true — OLD tracked; `insert` is true — NEW untracked; so /// `recordRename`'s precondition holds), then persists the managed set DURABLY /// before the working file moves (B-mv-2 / B-commit-15): write-temp + fsync + /// rename-replace. The move is the single non-atomic seam — a crash after the /// persist but before it leaves NEW tracked with the record pending and the file /// still at OLD (recoverable, never a silent wrong-bytes commit). Emits the one /// confirmation row to stdout on success; diagnostics stay on stderr (B-mv-17). fn mvCommit( io: std.Io, root_dir: std.Io.Dir, set: *managed.ManagedSet, old_key: []const u8, new_key: []const u8, new_arg: []const u8, streams: cli.Streams, ) cli.Exit { std.debug.assert(set.remove(old_key)); const inserted = set.insert(new_key) catch |e| return mvFail(streams, new_arg, e); std.debug.assert(inserted); set.recordRename(old_key, new_key) catch |e| return mvFail(streams, new_arg, e); set.write(io, root_dir) catch |e| return mvFail(streams, "", e); root_dir.rename(old_key, root_dir, new_key, io) catch return mvRenameFail(streams, old_key, new_key); streams.out.print("renamed {s} -> {s}\n", .{ old_key, new_key }) catch {}; return .ok; } /// mv takes exactly two operands; fewer is the exit-2 usage class (B-mv-14). fn mvMissingOperand(s: cli.Streams) cli.Exit { s.err.writeAll("zit mv: two operands required: OLD NEW\ntry 'zit mv --help'\n") catch {}; return .usage; } /// Renders an operand refusal (B-mv-3..5 shape): `zit mv: '<arg>' <phrase>`, /// exit 1 (data-layer failure, distinct from the exit-2 usage class). fn mvRefuse(s: cli.Streams, arg: []const u8, phrase: []const u8) cli.Exit { s.err.print("zit mv: '{s}' {s}\n", .{ arg, phrase }) catch {}; return .failure; } /// The managed set was durably swapped but the working file could not be moved /// (B-mv-2's non-atomic seam, stated honestly). Exit 1; the tree is recoverable. fn mvRenameFail(s: cli.Streams, old_key: []const u8, new_key: []const u8) cli.Exit { s.err.print( "zit mv: recorded '{s}' as '{s}' but could not move the working file: input/output error\n", .{ old_key, new_key }, ) catch {}; return .failure; } /// Renders a B-mv-16 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. The read-context wording matches /// the add/rm parity (`cannot read tracked files: .zit-managed is corrupt`). fn mvFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit mv: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit mv: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit mv: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit mv: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit mv: out of memory\n") catch {}, } return .failure; } /// The repository file's fixed name (logstore §E: a copied `.zit` is a /// complete repository). const repo_filename = zit.repo_filename; // --- educating diagnostics (all to err, all exit 1) ----------------------- fn renderAlreadyExists(s: cli.Streams, abs_zit: []const u8) cli.Exit { s.err.print("zit: a zit repository already exists at {s}\n", .{abs_zit}) catch {}; return .failure; } fn renderNotDir(s: cli.Streams, path: []const u8) cli.Exit { s.err.print("zit: cannot initialize: {s} is not a directory\n", .{path}) catch {}; return .failure; } fn renderStorage(s: cli.Streams, abs_dir: []const u8, reason: []const u8) cli.Exit { s.err.print("zit: cannot initialize repository at {s}: {s}\n", .{ abs_dir, reason }) catch {}; return .failure; } /// Maps a raw error to a short, honest, human phrase for the B-init-9 line. /// Pure rendering, not logic — the switch is over the union of every error /// the init path can surface (filesystem, `LogStore`, `checkout`). fn reasonOf(err: anyerror) []const u8 { return switch (err) { error.OutOfMemory => "out of memory", error.AccessDenied, error.PermissionDenied => "permission denied", error.ReadOnlyFileSystem => "read-only file system", error.NoSpaceLeft, error.DiskQuota => "no space left on device", error.FileNotFound => "no such file or directory", error.Corrupt => "repository is corrupt", // `error.Storage` (LogStore/checkout device failure) and every other // device/OS error collapse to one honest phrase. else => "input/output error", }; } // --- help / usage text ---------------------------------------------------- // ========================================================================== // `zit status` — read-only report of the working copy (contracts/status.md // B-status-1..17). A thin consumer: it parses, discovers the checkout root, // reads `.zit-checkout` and `.zit-managed` through the library, stats each // tracked path, and renders. No logic enters any library module; no log frame // is opened; no `logstore`/`repostate`/`artifact`/`delta`/`std.http`. The // committed-tree diff (edited/clean/deleted-from-check-in, untracked files) is // honestly deferred to `commit`/`diff` (B-status-16), stated in `--help`. // ========================================================================== /// `zit status [-h|--help]` — the thin wiring: parse, discover the checkout /// root, read the checkout record and the managed set, then one pass over the /// sorted set stat'ing each path and rendering. Read-only: a dirty working copy /// is never a failure (B-status-15). Mirrors `runAdd`'s order (parse → discover /// → load → render). fn runStatus( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-status-1). --help wins over everything (B-status-14). const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "status", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "status", .extra_argument, tok), }; if (help) { streams.out.writeAll(status_help) catch {}; return .ok; } // 2. Discover the checkout root (B-status-2/-9). OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "status")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Read the checkout record (B-status-10). The buffer holds one byte past // the record bound so `checkout.read`'s `out_buf.len > max_ck_payload` // assertion holds and an oversized file is detected, not truncated. var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(io, root_dir, &ck_buf) catch |e| return checkoutFail(streams, "status", e)) orelse return checkoutMissing(streams, "status"); // null after a successful discovery // 4. Load the managed set (B-status-11); an absent file reads as the empty // set (`ManagedSet.read`), so a repo with no `zit add` is not an error. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "status", e); defer set.deinit(); // 5. Header (B-status-3/-4/-17). An empty branch field defaults to trunk; // an empty version is the unborn state (no check-ins yet). const branch = if (ck.branch.len == 0) "trunk" else ck.branch; const unborn = ck.version.len == 0; if (unborn) streams.out.print("On branch {s} (no check-ins yet)\n", .{branch}) catch {} else streams.out.print("On branch {s} (check-in {s})\n", .{ branch, ck.version }) catch {}; // 6. One pass over the sorted set (B-status-5/-6/-8). A path is present iff a // no-follow stat succeeds AND its kind is a regular file — a symlink, a // directory, or any non-regular entry folds to `missing` (B-status-5). // `added`/`missing` interleave in path order (not grouped by kind). var changes: usize = 0; for (set.iterate()) |path| { const present = blk: { const st = root_dir.statFile(io, path, .{ .follow_symlinks = false }) catch break :blk false; break :blk st.kind == .file; }; if (!present) { streams.out.print("missing {s}\n", .{path}) catch {}; changes += 1; } else if (unborn) { streams.out.print("added {s}\n", .{path}) catch {}; changes += 1; } // born + present → not classified: distinguishing edited from clean // needs the committed-tree diff deferred to commit/diff (B-status-4/-16). } // 7. Clean-state line when no change line was emitted (B-status-7). if (changes == 0) { if (unborn and set.iterate().len == 0) streams.out.writeAll("nothing tracked yet\n") catch {} else streams.out.writeAll("nothing to report\n") catch {}; } return .ok; // read-only; a dirty working copy is never a failure (B-status-15) } /// Renders a checkout-state read failure for any verb; the verb prefixes the /// message so one mapper serves both the read-only working-tree verbs and commit. fn checkoutFail(s: cli.Streams, verb: []const u8, err: checkout.CheckoutError) cli.Exit { switch (err) { error.Corrupt => s.err.print( "zit {s}: cannot read checkout state: .zit-checkout is corrupt\n", .{verb}, ) catch {}, error.Storage => s.err.print( "zit {s}: cannot read checkout state: input/output error\n", .{verb}, ) catch {}, } return .failure; } /// The checkout record vanished between discovery and read. fn checkoutMissing(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: cannot read checkout state: .zit-checkout is missing\n", .{verb}) catch {}; return .failure; } /// Renders a managed-set read failure for any verb; the verb prefixes the /// message so one mapper serves both the read-only working-tree verbs and commit. fn managedFail(s: cli.Streams, verb: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.print( "zit {s}: cannot read tracked files: .zit-managed is corrupt\n", .{verb}, ) catch {}, error.Storage => s.err.print( "zit {s}: cannot read tracked files: input/output error\n", .{verb}, ) catch {}, error.TooLong => s.err.print( "zit {s}: cannot read tracked files: a tracked path is too long\n", .{verb}, ) catch {}, error.TooMany => s.err.print("zit {s}: cannot read tracked files: too many tracked files\n", .{verb}) catch {}, error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, } return .failure; } fn stateOom(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: out of memory\n", .{verb}) catch {}; return .failure; } // ========================================================================== // `zit extras` — list the working-tree files zit is not tracking. Read-only: // discover the root, read the managed set, sweep the checkout once through the // `extras.zig` deep module, then render the sorted list. The verb only renders; // the sweep (and its bounds/ordering) lives in the module (contracts/extras.md). // ========================================================================== fn runExtras( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-extras-11/-12/-13). --help wins over any trailing token. const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "extras", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "extras", .extra_argument, tok), }; if (help) { streams.out.writeAll(extras_help) catch {}; return .ok; } // 2. Discover the checkout root (B-extras-14). OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "extras")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Load the managed set (B-extras-4/-15); an absent file reads as the // empty set, so a repo with no `zit add` sweeps every on-disk file. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "extras", e); defer set.deinit(); // 4. Re-open the root ITERABLE (B-extras-18): `discoverRoot`'s handle is // non-iterable, and the sweep needs to enumerate. No-follow so a symlink // at the root is never entered. var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "extras", error.Storage); defer iter_root.close(io); // 5. Sweep once (B-extras-1..9). The module owns ordering and bounds. var result = extras.sweep(gpa, io, iter_root, &set) catch |e| return sweepFail(streams, "extras", e); defer result.deinit(); // 6. Render: one root-relative path per line, ascending byte order // (B-extras-2/-3). An empty set prints nothing and still exits 0 // (B-extras-10). Streaming writes honor an inherited O_APPEND offset // under `>>` (B-extras-17). for (result.items()) |path| streams.out.print("{s}\n", .{path}) catch {}; return .ok; } /// Renders a shared-sweep failure to stderr and returns `.failure` (exit 1, the /// data-layer class, distinct from the exit-2 usage class). `verb` names the /// caller so `extras` and `clean` each prefix their own diagnostics. Exhaustive /// over the closed `extras.SweepError` set — a new variant fails to compile. fn sweepFail(s: cli.Streams, verb: []const u8, err: extras.SweepError) cli.Exit { switch (err) { error.Storage => s.err.print("zit {s}: cannot read the checkout: input/output error\n", .{verb}) catch {}, error.TooMany => s.err.print("zit {s}: too many files to enumerate\n", .{verb}) catch {}, error.TooDeep => s.err.print("zit {s}: checkout tree is nested too deeply\n", .{verb}) catch {}, error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, } return .failure; } // ========================================================================== // `zit clean` — delete the working-tree files zit is not tracking. It consumes // the SAME `extras.zig` sweep unchanged (B-clean-18) and adds exactly one new // mechanism: deletion. Tracked and control paths are untouchable BY // CONSTRUCTION — clean acts only on the set the sweep hands it and performs no // membership re-check (B-clean-7). clean is the destructive companion to // `extras` and is NOT sheltered by undo; deletion is final (contracts/clean.md). // ========================================================================== /// `zit clean [--dry-run] [--force]` — the thin wiring: parse, discover the /// checkout root, read the managed set, sweep once through the shared module, /// then branch. Un-forced is a gate that refuses and deletes nothing /// (B-clean-1/-2); `--dry-run` previews byte-identically to `zit extras` /// (B-clean-3/-4); `--force` deletes exactly the swept set (B-clean-5..10). /// Mirrors `runExtras` through the sweep, then diverges only in step 6. fn runClean( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-clean-12/-13/-14). --help wins over any trailing token and over // --force — help never sweeps and never deletes. const args = switch (cli.parseCleanArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "clean", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "clean", .extra_argument, tok), }; if (args.help) { streams.out.writeAll(clean_help) catch {}; return .ok; } // 2. Discover the checkout root (B-clean-15): the repository check precedes // any sweep, regardless of --force/--dry-run. OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "clean")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Load the managed set (B-clean-16): a tree whose tracked set cannot be // read is a tree clean must not touch — fail cleanly BEFORE any deletion. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "clean", e); defer set.deinit(); // 4. Re-open the root ITERABLE (B-clean-18), exactly as extras: the sweep // enumerates and the deletion pass unlinks through this same handle. var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "clean", error.Storage); defer iter_root.close(io); // 5. Sweep once through the SHARED module (B-clean-18). The extras set is // clean's SOLE path source — no re-walk, no re-derivation. var result = extras.sweep(gpa, io, iter_root, &set) catch |e| return sweepFail(streams, "clean", e); defer result.deinit(); // 6. Branch. Order encodes the B-clean-11 precedence: --dry-run wins, so it is // checked before --force and can never be upgraded into a real deletion. if (args.dry_run) { // Preview byte-identically to `zit extras` (B-clean-3/-4): one // root-relative path per line, ascending, deleting nothing. for (result.items()) |path| streams.out.print("{s}\n", .{path}) catch {}; return .ok; } if (!args.force) { // The gate (B-clean-1/-2/-8): an empty set is a silent success no-op with // nothing to guard; otherwise refuse without deleting, naming the count. if (result.items().len == 0) return .ok; return cleanRefuse(streams, result.items().len); } // --force: delete exactly the swept set (B-clean-5..10). return cleanForce(streams, io, iter_root, &result); } /// Renders the B-clean-1 two-line refusal to stderr and returns `.failure` /// (exit 1). The count is `> 0` by construction — the caller handles the empty /// set as a silent success before reaching here (B-clean-8). Non-interactive: /// no prompt is ever issued, stdin is never read (B-clean-2). fn cleanRefuse(s: cli.Streams, count: usize) cli.Exit { std.debug.assert(count > 0); const noun: []const u8 = if (count == 1) "file" else "files"; s.err.print( "zit clean: {d} untracked {s} would be deleted; refusing without --force\n", .{ count, noun }, ) catch {}; s.err.writeAll( "run 'zit clean --dry-run' to preview, or 'zit clean --force' to delete\n", ) catch {}; return .failure; } /// Deletes exactly the swept extras set (B-clean-5) through `root` — the same /// ITERABLE handle the sweep enumerated, so no path outside that set is ever /// reachable and tracked/control files stay untouched BY CONSTRUCTION /// (B-clean-7: no membership re-check). A failed unlink is reported to stderr /// and the sweep CONTINUES its siblings (B-clean-9); the run then exits 1 /// (B-clean-10). An already-absent path satisfies the postcondition and counts /// as success (idempotent, B-clean-6). Full success is silent on stdout and /// exits 0 (cli-stream-separation). fn cleanForce(s: cli.Streams, io: std.Io, root: std.Io.Dir, result: *const extras.Extras) cli.Exit { var failed = false; // Bound = the finite swept count (B-clean-18): no re-walk, no recursion. for (result.items()) |path| { std.debug.assert(path.len > 0); // shape invariant, NOT a membership re-check root.deleteFile(io, path) catch |e| switch (e) { error.FileNotFound => {}, // already gone ⇒ postcondition holds (B-clean-6) else => { failed = true; s.err.print( "zit clean: '{s}': could not delete: {s}\n", .{ path, reasonOf(e) }, ) catch {}; }, }; } return if (failed) .failure else .ok; } // ========================================================================== // `zit diff` — read-only metadata comparison of managed paths against the // checkout's fully resolved F-set. It buffers every record before writing so // an I/O/corruption/OOM failure never leaves a partial report on stdout. // ========================================================================== /// Upper bound on one managed working-tree file read by `commit` or `diff`. /// The boundary is inclusive, so a file exactly this size is valid; larger /// files are refused before allocation. const max_tracked_file_bytes: usize = 1 << 30; fn trackedFileSizeAllowed(size: u64) bool { return size <= @as(u64, max_tracked_file_bytes); } const DiffInspection = enum { none, added, same, modified, deleted, io_error, oom, corrupt, }; const DiffBaselineResolver = struct { const Failure = enum { unresolved, storage, corrupt, oom }; gpa: std.mem.Allocator, log: *logstore.LogStore, bytes: ?[]u8 = null, manifest: ?artifact.Manifest = null, /// Non-null when the read-path shim normalized a legacy trailing-separator /// F card; the manifest then borrows this copy instead of `bytes`. normalized: ?[]u8 = null, failure: Failure = .unresolved, fn deinit(self: *DiffBaselineResolver) void { if (self.manifest) |*m| m.deinit(); if (self.normalized) |b| self.gpa.free(b); if (self.bytes) |bytes| self.gpa.free(bytes); self.* = undefined; } fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const self: *DiffBaselineResolver = @ptrCast(@alignCast(ctx)); var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeDiffId(hash, &digest_buf) orelse return null; const rid = self.log.lookup(digest) orelse return null; const bytes = self.log.content(self.gpa, rid) catch |err| { self.failure = switch (err) { error.OutOfMemory => .oom, error.Corrupt => .corrupt, error.Storage => .storage, }; return null; } orelse return null; self.bytes = bytes; if (!artifact.verify(hash, bytes)) { self.failure = .corrupt; return null; } // Read path: tolerate the legacy F-card trailing separator in // fossil-origin history when resolving a diff baseline. const im = artifact.parseForImport(self.gpa, bytes) catch |err| { self.failure = if (err == error.OutOfMemory) .oom else .corrupt; return null; }; self.manifest = im.manifest; self.normalized = im.normalized; if (self.manifest.?.kind != .manifest) return null; return .{ .baseline_hash = if (self.manifest.?.b) |b| b.hash else null, .files = self.manifest.?.f, }; } }; /// Decodes a canonical 40- or 64-hex artifact name into the raw log key. fn decodeDiffId(name: []const u8, out: *[logstore.max_hash_len]u8) ?[]const u8 { if (!artifact.validateName(name)) return null; const algo = artifact.nameAlgo(name.len) orelse return null; const len: usize = switch (algo) { .sha1 => 20, .sha3_256 => 32, }; _ = std.fmt.hexToBytes(out[0..len], name) catch return null; return out[0..len]; } fn executable(permissions: File.Permissions) bool { if (comptime File.Permissions.has_executable_bit) { return permissions.toMode() & 0o111 != 0; } return false; } fn absentDiffOutcome(baseline: ?artifact.FCard) DiffInspection { return if (baseline == null) .none else .deleted; } /// Inspects one managed pathname without following it. The path is no-follow /// stated before opening; its opened handle is then stated and read, so neither /// a symlink nor a replacement after the initial stat can be followed. fn inspectDiffFile( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, path: []const u8, baseline: ?artifact.FCard, ) DiffInspection { const path_stat = root_dir.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => return absentDiffOutcome(baseline), else => return .io_error, }; if (path_stat.kind != .file) return absentDiffOutcome(baseline); var file = root_dir.openFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound, error.SymLinkLoop => return absentDiffOutcome(baseline), else => return .io_error, }; defer file.close(io); const st = file.stat(io) catch return .io_error; if (st.kind != .file) return absentDiffOutcome(baseline); const f = baseline orelse return .added; const working_mode: artifact.FileMode = if (executable(st.permissions)) .executable else .regular; if (working_mode != f.mode) return .modified; if (!trackedFileSizeAllowed(st.size)) return .io_error; const content = gpa.alloc(u8, @intCast(st.size)) catch return .oom; defer gpa.free(content); const n = file.readPositionalAll(io, content, 0) catch return .io_error; if (n != content.len) return .io_error; const algo = artifact.nameAlgo(f.hash.?.len) orelse return .corrupt; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(algo, content, &name_buf); return if (std.mem.eql(u8, name, f.hash.?)) .same else .modified; } fn appendDiffRecord( gpa: std.mem.Allocator, report: *std.ArrayList(u8), kind: u8, path: []const u8, ) error{OutOfMemory}!void { if (path.len > std.math.maxInt(usize) / 2) return error.OutOfMemory; try report.append(gpa, kind); try report.append(gpa, ' '); const encoded = try report.addManyAt(gpa, report.items.len, 2 * path.len); const escaped = artifact.encodeEscapes(path, encoded); report.items.len -= encoded.len - escaped.len; try report.append(gpa, '\n'); } fn appendDiffInspection( gpa: std.mem.Allocator, report: *std.ArrayList(u8), path: []const u8, inspection: DiffInspection, ) error{ OutOfMemory, Storage, Corrupt }!void { switch (inspection) { .none, .same => {}, .added => try appendDiffRecord(gpa, report, 'A', path), .modified => try appendDiffRecord(gpa, report, 'M', path), .deleted => try appendDiffRecord(gpa, report, 'D', path), .io_error => return error.Storage, .oom => return error.OutOfMemory, .corrupt => return error.Corrupt, } } fn nextEffectiveFile(walk: *artifact.MergeWalk) ?artifact.FCard { while (walk.next()) |f| { if (f.hash != null) return f; } return null; } fn diffOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit diff: out of memory\n") catch {}; return .failure; } fn diffLogFail(s: cli.Streams, err: logstore.OpenError) cli.Exit { switch (err) { error.OutOfMemory => return diffOom(s), error.Corrupt => s.err.writeAll("zit diff: cannot read repository: repository is corrupt\n") catch {}, error.Storage => s.err.writeAll("zit diff: cannot read repository: input/output error\n") catch {}, } return .failure; } fn diffCurrentCorrupt(s: cli.Streams) cli.Exit { s.err.writeAll("zit diff: current check-in is not a valid manifest\n") catch {}; return .failure; } fn diffBaselineFail(s: cli.Streams, failure: DiffBaselineResolver.Failure) cli.Exit { switch (failure) { .oom => return diffOom(s), .storage => return diffLogFail(s, error.Storage), .corrupt => return diffLogFail(s, error.Corrupt), .unresolved => s.err.writeAll("zit diff: cannot resolve baseline\n") catch {}, } return .failure; } fn diffComparisonFail(s: cli.Streams, err: error{ OutOfMemory, Storage, Corrupt }) cli.Exit { switch (err) { error.OutOfMemory => return diffOom(s), error.Corrupt => s.err.writeAll("zit diff: cannot compare: repository is corrupt\n") catch {}, error.Storage => s.err.writeAll("zit diff: cannot compare working tree: input/output error\n") catch {}, } return .failure; } fn writeDiffOutput(s: cli.Streams, bytes: []const u8) cli.Exit { s.out.writeAll(bytes) catch { s.err.writeAll("zit diff: cannot write output\n") catch {}; return .failure; }; return .ok; } fn runDiff( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "diff", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "diff", .extra_argument, tok), }; if (help) { return writeDiffOutput(streams, diff_help); } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "diff")) 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 |err| return checkoutFail(streams, "diff", err)) orelse return checkoutMissing(streams, "diff"); var set = managed.ManagedSet.read(gpa, io, root_dir) catch |err| return managedFail(streams, "diff", err); defer set.deinit(); var report: std.ArrayList(u8) = .empty; defer report.deinit(gpa); const paths = set.iterate(); if (ck.version.len == 0) { for (paths) |path| { appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err| return diffComparisonFail(streams, err); } return writeDiffOutput(streams, report.items); } var repo_file = root_dir.openFile(io, repo_filename, .{}) catch return diffLogFail(streams, error.Storage); defer repo_file.close(io); const repo_stat = repo_file.stat(io) catch return diffLogFail(streams, error.Storage); var fs = storage.FileStorage.init(io, repo_file, repo_stat.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |err| return diffLogFail(streams, err); defer log.deinit(gpa); var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeDiffId(ck.version, &digest_buf) orelse return diffCurrentCorrupt(streams); const rid = log.lookup(digest) orelse return diffCurrentCorrupt(streams); const current_bytes = (log.content(gpa, rid) catch |err| return diffLogFail(streams, err)) orelse return diffCurrentCorrupt(streams); defer gpa.free(current_bytes); if (!artifact.verify(ck.version, current_bytes)) return diffCurrentCorrupt(streams); // Read path: tolerate the legacy F-card trailing separator when reading the // current check-in (fossil-origin history) for a diff. var current_loaded = artifact.parseForImport(gpa, current_bytes) catch |err| switch (err) { error.OutOfMemory => return diffOom(streams), else => return diffCurrentCorrupt(streams), }; defer current_loaded.deinit(gpa); const current = ¤t_loaded.manifest; if (current.kind != .manifest) return diffCurrentCorrupt(streams); var resolver_ctx: DiffBaselineResolver = .{ .gpa = gpa, .log = &log }; defer resolver_ctx.deinit(); var walk = artifact.enumerate(.{ .baseline_hash = if (current.b) |b| b.hash else null, .files = current.f, }, .{ .ctx = &resolver_ctx, .resolveFn = DiffBaselineResolver.resolve, }) catch |err| switch (err) { error.BaselineUnresolvable => return diffBaselineFail(streams, resolver_ctx.failure), error.BaselineIsDelta => return diffBaselineFail(streams, .unresolved), }; var path_index: usize = 0; var baseline = nextEffectiveFile(&walk); while (path_index < paths.len or baseline != null) { if (baseline) |f| { if (path_index == paths.len) { appendDiffRecord(gpa, &report, 'D', f.filename) catch return diffOom(streams); baseline = nextEffectiveFile(&walk); continue; } const path = paths[path_index]; switch (std.mem.order(u8, path, f.filename)) { .lt => { appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err| return diffComparisonFail(streams, err); path_index += 1; }, .eq => { appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, f)) catch |err| return diffComparisonFail(streams, err); path_index += 1; baseline = nextEffectiveFile(&walk); }, .gt => { appendDiffRecord(gpa, &report, 'D', f.filename) catch return diffOom(streams); baseline = nextEffectiveFile(&walk); }, } } else { const path = paths[path_index]; appendDiffInspection(gpa, &report, path, inspectDiffFile(gpa, io, root_dir, path, null)) catch |err| return diffComparisonFail(streams, err); path_index += 1; } } return writeDiffOutput(streams, report.items); } /// The diagnostic every working-tree verb (add/rm/status) prints when discovery /// finds no repository. Shared so all three fail identically (B-add-8 / B-rm-8 / /// B-status-9 "identical shape") — a single, byte-identical line, exactly as each /// contract quotes it. const not_a_repo_msg = "zit: not a zit repository (no .zit found in this directory or any parent)\n"; // ========================================================================== // `zit log CHECKIN` — one explicit check-in and its reachable parent graph. // ========================================================================== const LogWalkError = error{ OutOfMemory, Storage, Corrupt, MissingRoot, InvalidRoot, InvalidTarget, }; fn decodeFullId(id: []const u8, out: *[digest_len]u8) error{InvalidId}![]const u8 { if (!artifact.validateName(id)) return error.InvalidId; const decoded = out[0 .. id.len / 2]; _ = std.fmt.hexToBytes(decoded, id) catch return error.InvalidId; return decoded; } /// The optional PATH restriction for a `zit log CHECKIN PATH` walk (B-log-2). /// `repo` answers the one general-purpose §8 effective-set query (`fileAt`) the /// change predicate consumes (B-log-5); `path` is the exact repo-relative name. const LogPathFilter = struct { repo: *Repository, path: []const u8, }; fn walkLog( gpa: std.mem.Allocator, log: *logstore.LogStore, root_id: []const u8, out: *Io.Writer, filter: ?LogPathFilter, ) LogWalkError!void { var pending: std.ArrayList([]u8) = .empty; defer { for (pending.items) |id| gpa.free(id); pending.deinit(gpa); } var visited: std.StringHashMapUnmanaged(void) = .empty; defer visited.deinit(gpa); var visited_ids: std.ArrayList([]u8) = .empty; defer { for (visited_ids.items) |id| gpa.free(id); visited_ids.deinit(gpa); } const root_copy = try gpa.dupe(u8, root_id); pending.append(gpa, root_copy) catch { gpa.free(root_copy); return error.OutOfMemory; }; while (pending.pop()) |id| { if (visited.contains(id)) { gpa.free(id); continue; } visited_ids.append(gpa, id) catch { gpa.free(id); return error.OutOfMemory; }; visited.put(gpa, id, {}) catch { _ = visited_ids.pop(); gpa.free(id); return error.OutOfMemory; }; const is_root = visited_ids.items.len == 1; var hash_buf: [digest_len]u8 = undefined; const decoded = decodeFullId(id, &hash_buf) catch return if (is_root) error.InvalidRoot else error.InvalidTarget; const rid = log.lookup(decoded) orelse { if (is_root) return error.MissingRoot; // A phantom's effective set is unknowable, so the PATH filter cannot // assert it changes PATH — it is silently skipped (the unfiltered // walk still names it once). if (filter == null) out.print("phantom {s}\n", .{id}) catch {}; continue; }; const bytes = (try log.content(gpa, rid)) orelse { if (is_root) return error.MissingRoot; if (filter == null) out.print("phantom {s}\n", .{id}) catch {}; continue; }; defer gpa.free(bytes); if (!artifact.verify(id, bytes)) return error.Corrupt; // Read path: the log-walk reads fossil-origin history, so it tolerates // the legacy F-card trailing separator. var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return if (is_root) error.InvalidRoot else error.InvalidTarget, }; defer loaded.deinit(gpa); const manifest = &loaded.manifest; if (manifest.kind != .manifest) return if (is_root) error.InvalidRoot else error.InvalidTarget; // With no PATH the walk names every reachable check-in (B-log-1); with // PATH it names only the check-ins whose effective content for PATH // differs from their primary parent's (B-log-2). if (filter) |f| { if (try logChangesPath(gpa, f.repo, id, manifest, f.path)) out.print("checkin {s}\n", .{id}) catch {}; } else { out.print("checkin {s}\n", .{id}) catch {}; } // Push in reverse wire order so the LIFO stack visits the first P hash // first. Reversing only this node's appended segment preserves DFS when // older sibling work is already below it. const parent_start = pending.items.len; for (manifest.p) |pc| { var hashes = pc.hashes(); while (hashes.next()) |parent| { const owned = try gpa.dupe(u8, parent); pending.append(gpa, owned) catch { gpa.free(owned); return error.OutOfMemory; }; } } std.mem.reverse([]u8, pending.items[parent_start..]); } } /// PATH's presence and effective content hash inside one check-in's file set. /// The hash is a 40/64-char lowercase-hex F-card token (§4.7.6), copied into a /// fixed buffer so the comparison outlives `fileAt`'s duped card. const PathContent = struct { present: bool, hash: [2 * digest_len]u8 = undefined, hash_len: usize = 0, }; /// The effective content of PATH in the check-in named `id` (artifact §8 /// reconstruction via `Repository.fileAt` — the one general-purpose query, so /// no CLI-side re-derivation off raw F cards, B-log-5). `present` is false when /// `id` is not interned (a phantom the derived view never crosslinked) or PATH /// is not in its effective set (never added, or a delta delete marker §4.7.6). fn effectivePathContent( gpa: std.mem.Allocator, repo: *Repository, id: []const u8, path: []const u8, ) LogWalkError!PathContent { const rid = repo.lookup(id) orelse return .{ .present = false }; const card = (repo.fileAt(gpa, rid, path) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, // NotManifest/BaselineUnresolvable/BaselineIsDelta/Corrupt/NotARepo all // mean the reachable check-in cannot be reconstructed — the same class // the unfiltered walk reports as a corrupt/invalid target. else => error.Corrupt, }) orelse return .{ .present = false }; defer Repository.freeFileCard(gpa, card); // The iterator behind `fileAt` suppresses delete markers, so a returned // card always carries a hash (§4.7.6). const h = card.hash.?; std.debug.assert(h.len <= 2 * digest_len); var out: PathContent = .{ .present = true, .hash_len = h.len }; @memcpy(out.hash[0..h.len], h); return out; } /// Whether the check-in `id` changes PATH per the B-log change definition: its /// effective content (or presence) for PATH differs from its PRIMARY parent's /// (§3.2 first-listed P-card parent). A root has no parent, so presence alone is /// a change; a rename surfaces at both names as a presence flip (no old-name /// following, the B-merge-22 delete+add posture). `manifest` is the already- /// parsed check-in the walk holds. fn logChangesPath( gpa: std.mem.Allocator, repo: *Repository, id: []const u8, manifest: *const artifact.Manifest, path: []const u8, ) LogWalkError!bool { const here = try effectivePathContent(gpa, repo, id, path); // §3.2: the primary parent is the first hash of the first P card. var primary: ?[]const u8 = null; for (manifest.p) |pc| { var hashes = pc.hashes(); if (hashes.next()) |first| { primary = first; break; } } const parent = if (primary) |pid| try effectivePathContent(gpa, repo, pid, path) else PathContent{ .present = false }; if (here.present != parent.present) return true; if (!here.present) return false; return !std.mem.eql(u8, here.hash[0..here.hash_len], parent.hash[0..parent.hash_len]); } /// `zit log CHECKIN PATH` — the PATH-restricted ancestry walk (B-log-2). Opens /// the `Repository` facade for the §8 effective-set query, then reuses the exact /// unfiltered DFS (`walkLog`) so order and line layout match B-log-1, gating each /// `checkin` line on the change predicate. `root_dir` is borrowed (the caller /// closes it after this returns). fn runLogPath( gpa: std.mem.Allocator, io: std.Io, streams: cli.Streams, root_dir: std.Io.Dir, root_id: []const u8, path: []const u8, ) cli.Exit { var repo: Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch |e| return repoOpenFail(streams, "log", e); defer repo.deinit(gpa); const log = repo.logStore(gpa) catch |e| return repoOpenFail(streams, "log", e); walkLog(gpa, log, root_id, streams.out, .{ .repo = &repo, .path = path }) catch |e| { switch (e) { error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {}, error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {}, error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {}, error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {}, error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}, error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, } return .failure; }; return .ok; } fn runLog( 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.parseLogArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "log", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "log", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(log_help) catch {}; return .ok; } const root_id = parsed.checkin orelse return cli.usageError(streams, "log", .no_checkin, ""); if (!artifact.validateName(root_id)) { streams.err.print( "zit log: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n", .{root_id}, ) catch {}; return .failure; } const root = (cli.discoverRoot(arena, io) catch { streams.err.writeAll("zit log: 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); // With a PATH the walk is restricted to the check-ins that change it // (B-log-2), a route that needs the `Repository` facade for §8 effective-set // queries; without one the walk is byte-identical to the shipped behavior // (B-log-1), so the no-PATH path below is left exactly as it was. if (parsed.path) |path| return runLogPath(gpa, io, streams, root_dir, root_id, path); var file = root_dir.openFile(io, repo_filename, .{}) catch { streams.err.writeAll("zit log: 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 log: 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 |e| { switch (e) { error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, else => streams.err.print("zit log: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}, } return .failure; }; defer log.deinit(gpa); walkLog(gpa, &log, root_id, streams.out, null) catch |e| { switch (e) { error.MissingRoot => streams.err.print("zit log: check-in '{s}' not found\n", .{root_id}) catch {}, error.InvalidRoot => streams.err.print("zit log: check-in '{s}' is not a valid manifest\n", .{root_id}) catch {}, error.InvalidTarget => streams.err.writeAll("zit log: reachable check-in is not a valid manifest\n") catch {}, error.Corrupt => streams.err.writeAll("zit log: cannot read repository: repository is corrupt\n") catch {}, error.Storage => streams.err.writeAll("zit log: cannot read repository: input/output error\n") catch {}, error.OutOfMemory => streams.err.writeAll("zit log: out of memory\n") catch {}, } return .failure; }; return .ok; } /// One honest phrase for a repository the read-only family cannot open or stat. fn repoReadFail(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {}; return .failure; } /// Maps a `Repository.open` failure to the verb's diagnostic + exit 1. Closed /// over `Repository.OpenError` so the CLI switch stays exhaustive; general over /// the verb name so every read-only verb shares one map. The `not-a-repo` and /// corrupt/IO phrasings are byte-identical to the pre-facade hand-wired opens. fn repoOpenFail(s: cli.Streams, verb: []const u8, err: Repository.OpenError) cli.Exit { switch (err) { error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {}, error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {}, } return .failure; } /// Discover the checkout root and open the repository through the `Repository` /// facade for the read-only verb family (leaves, branch, tag, info). One place /// owns the discover + open + per-verb diagnostics so every read-only verb reads /// identical text. On `.ok`, `repo` is filled (caller `deinit`s it) and /// `root_dir` is set (caller closes it AFTER `repo.deinit`); on any non-`.ok` /// the diagnostic is already on stderr, `repo` is untouched, and any opened root /// dir has been closed here. fn openRepo( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, verb: []const u8, bypass_cache: bool, fast_read: bool, repo: *Repository, root_dir: *std.Io.Dir, ) cli.Exit { const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, verb)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; // Hot verbs take the zero-parse image path (`.fast_read`); the cold verbs // that read adjacency or the full tag tables stay on the full derive. repo.open(gpa, io, root.dir, .{ .bypass_cache = bypass_cache, .mode = if (fast_read) .fast_read else .full, }) catch |e| { var d = root.dir; d.close(io); return repoOpenFail(streams, verb, e); }; root_dir.* = root.dir; return .ok; } /// `zit serve [--port N]` — put an HTTP face on the stateless sync responder. /// Opens `.zit` read-only and keeps it open for the server's lifetime; every /// request reads a fresh content view of it and is answered independently /// (sync §3.8). `std.http`/socket I/O lives entirely in `serve.zig`; this only /// wires the repository and the listen address. fn runServe( 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.parseServeArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "serve", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "serve", .extra_argument, tok), .bad_port => |tok| { streams.err.print("zit serve: invalid --port value '{s}'\n", .{tok}) catch {}; return .usage; }, }; if (parsed.help) { streams.out.writeAll(serve_help) catch {}; return .ok; } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "serve")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; var file = root_dir.openFile(io, repo_filename, .{}) catch { root_dir.close(io); return repoReadFail(streams, "serve"); }; defer file.close(io); const log_st = file.stat(io) catch { root_dir.close(io); return repoReadFail(streams, "serve"); }; var fs = storage.FileStorage.init(io, file, log_st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| { root_dir.close(io); switch (e) { error.OutOfMemory => streams.err.writeAll("zit serve: out of memory\n") catch {}, else => streams.err.print("zit serve: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}, } return .failure; }; defer log.deinit(gpa); defer root_dir.close(io); // §9.1/§8.2 — emit the repository's stored project code in the clone // reply's `push` card, so a clone learns the shared project identity. A // repo predating the stored code, or an unreadable config, falls back to // the fixed placeholder (informational; servercode never gates a clone). var maybe_cfg: ?logstore.OwnedConfig = log.readConfig(gpa) catch null; defer if (maybe_cfg) |*oc| oc.deinit(gpa); const project_code: []const u8 = if (maybe_cfg) |oc| (if (oc.config.project_code.len > 0) oc.config.project_code else serve_mod.server_projectcode) else serve_mod.server_projectcode; const server = zit.sync.Server{ .version = serve_mod.server_version, .credential = null, .self_servercode = serve_mod.server_servercode, .project_code = project_code, }; var ctx = ServeCtx{ .io = io, .root_dir = root_dir, .file = file, .server = &server }; const responder = serve_mod.Responder{ .ctx = &ctx, .answerFn = serveAnswer }; const address = std.Io.net.IpAddress.parse("127.0.0.1", parsed.port) catch { streams.err.print("zit serve: invalid --port value '{d}'\n", .{parsed.port}) catch {}; return .usage; }; streams.err.print("zit serve: listening on http://127.0.0.1:{d}/xfer (Ctrl-C to stop)\n", .{parsed.port}) catch {}; streams.err.flush() catch {}; serve_mod.serve(io, gpa, address, responder, .{}) catch |e| switch (e) { error.Listen => { streams.err.print("zit serve: cannot bind 127.0.0.1:{d}\n", .{parsed.port}) catch {}; return .failure; }, error.Accept => { streams.err.writeAll("zit serve: connection accept failed\n") catch {}; return .failure; }, }; return .ok; } /// The per-request context threaded into `serve.Responder`: the open log to read /// a fresh store from, the server identity to answer with, and the injected IO + /// repository directory used to make pushed content durable (a push request /// ingests new artifacts into the per-request store, which must be persisted into /// the served repository's on-disk log — sync §3.3). const ServeCtx = struct { io: std.Io, root_dir: std.Io.Dir, /// The open read handle on the served `.zit`. `respond` snapshots the /// CURRENT on-disk log from it per request (§3.8): a fresh stat fixes the /// committed end-of-log, so a check-in appended by a separate `zit commit` /// while `serve` runs is advertised on the next request — no restart. file: File, server: *const zit.sync.Server, }; /// The `serve.Responder` seam: snapshot the repository into an in-memory store, /// answer the framed request over it, and — when the request pushed new content — /// make that content durable in the served repository's on-disk log (sync §3.3). /// /// `respond` stays sans-IO: it only `put`s pushed artifacts into the snapshot /// store it is given. The durable write is this serve-layer's job and reuses the /// idempotent `persistStore` (a re-push of an already-stored hash appends nothing /// — content is addressed by hash), the very path clone/pull persist through. A /// pure gimme/pull request grows the store by nothing, so `contentCount` stays /// flat and the log is never reopened; only a genuine push touches disk. /// /// The pushed bytes were size-validated by `respond`'s request scan before they /// entered the store, so `persistStore` only ever writes well-formed content, and /// a mid-write fault surfaces before `commit`, never as a partial log append. fn serveAnswer(ctx_ptr: *anyopaque, gpa: std.mem.Allocator, request_container: []const u8) serve_mod.AnswerError!serve_mod.XferReply { const ctx: *ServeCtx = @ptrCast(@alignCast(ctx_ptr)); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); try readServedStore(gpa, ctx.io, ctx.file, &ms); const held_before = contentCount(ms.store()); const reply = try serve_mod.handleXfer(gpa, ctx.server, ms.store(), request_container); errdefer gpa.free(reply.body); if (contentCount(ms.store()) > held_before) { _ = persistStore(gpa, ctx.io, ctx.root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt => return error.ServerStorage, }; } return reply; } /// Snapshot every stored artifact from the persisted log into `ms`, skipping /// config frames (not artifacts). A content-bearing frame becomes a held entry; /// a content-absent frame becomes a phantom — exactly the held/phantom split /// `respond` walks (sync §3.2/§3.3). /// /// The log keys frames by the RAW binary digest, but the sync wire names an /// artifact by that digest's lowercase hex (sync §3.3 — a `file`/`igot`/`gimme` /// HASH is 40/64 hex characters). The store the responder walks must therefore /// hold the hex name, or `respond` would emit non-hex bytes that break the /// peer's card tokenizer. Every hash crossing this boundary is hex-encoded once, /// here — the single place binary-log identity becomes wire identity. fn loadContentStore( gpa: std.mem.Allocator, log: *logstore.LogStore, ms: *repostate.MemStore, ) serve_mod.AnswerError!void { const rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory; defer gpa.free(rids); for (rids) |rid| { if (log.kindOf(rid) == .config) continue; var name_buf: [2 * digest_len]u8 = undefined; const name = digestToWireName(log.hashOf(rid), &name_buf); // sync §4.1/§7.2 — copy the stored container into the snapshot verbatim // so the clone serves it without inflating here and re-compressing at // serve. `wire()` is null only for a `.raw` frame or a phantom; those // fall back to the plaintext path below. A `.zlib` frame carries the // full form; a `.delta` frame carries the stored delta container plus // its basis name and reconstructed size (the §7.2 USIZE, delta §3.2). var maybe_wire = log.wire(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt => return error.ServerStorage, }; if (maybe_wire) |*w| { defer w.deinit(gpa); switch (w.*) { .full => |container| { _ = ms.putWire(name, container) catch return error.OutOfMemory; }, .delta => |d| { // The basis frame precedes this one in the log (delta_of < // offset), so it was already inserted at an earlier rid; its // wire name is the hex of the base's binary digest. var src_name_buf: [2 * digest_len]u8 = undefined; const src_name = digestToWireName(d.baseHash(), &src_name_buf); _ = ms.putWireDelta(name, d.container, src_name, d.target_size) catch return error.OutOfMemory; }, } continue; } const maybe = log.content(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt => return error.ServerStorage, }; if (maybe) |bytes| { defer gpa.free(bytes); _ = ms.store().put(name, bytes) catch return error.OutOfMemory; } else { _ = ms.store().intern(name) catch return error.OutOfMemory; } } } /// §3.8 — read the CURRENT on-disk log from the open serve handle `file` into /// `ms` for one request. The server is stateless per request, so its holdings /// are re-read every time rather than snapshotted at startup: a fresh `stat` /// fixes the committed end-of-log (a separate `zit commit` process, or this /// server's own push persist, appends complete frames that grow the file), and /// `openReadOnly` scans exactly that snapshot through a positional read — the /// handle is never advanced, so re-reading it sees the new bytes. A concurrent /// append caught mid-write leaves a torn tail, which the read-only scanner /// rejects as corruption (never a torn artifact); the committed frames below it /// are read consistently, and the request retries once the write completes. The /// upshot: a commit made while `serve` runs is advertised on the next request, /// with no restart (the Direction-A incremental-pull gap). fn readServedStore( gpa: std.mem.Allocator, io: std.Io, file: File, ms: *repostate.MemStore, ) serve_mod.AnswerError!void { const st = file.stat(io) catch return error.ServerStorage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt => return error.ServerStorage, }; defer log.deinit(gpa); try loadContentStore(gpa, &log, ms); } // --------------------------------------------------------------------------- // `zit clone URL DIR` — the interop milestone: a real client converging against // a real `zit serve` over HTTP. Create a fresh repo at DIR, open an HTTP // transport to URL, pump the sans-IO clone client (`sync.Client`) to §3.7 // convergence through `sync_driver.runSync`, then persist the received // artifacts into DIR's on-disk log so the clone is durable. Anonymous only. // --------------------------------------------------------------------------- /// §9.1 — a fixed placeholder repository identity emitted in the client's /// pull/push operation cards. Neither a fresh clone nor a pulled repository /// carries a persistent SERVERCODE/PROJECTCODE of its own yet (real per-repo /// sync identity is future work); the stateless server echoes the peer's codes /// and never gates on a configured project code (sync §9.1), so any well-formed /// 16-hex pair carries a content-only exchange. Shared by clone and pull. const sync_servercode = "0000000000000000"; const sync_projectcode = "0000000000000000"; /// §10.1 — the release integer the sync client announces as `pragma /// client-version`. Undated (no server-version is solicited); a fixed build /// identity, matching `serve`'s own version constant style. const sync_client_version: u32 = 22700; /// An explicit upper bound on sync rounds (TigerStyle: every loop is bounded). /// A single-hop reconcile converges in a handful of rounds; this is deep slack, /// so a misbehaving peer yields `error.RoundLimitExceeded` instead of hanging. const max_sync_rounds: usize = 1024; /// `zit clone URL DIR` — thin wiring over three deep modules: `transport` /// (HTTP/IO), `sync.Client` + `sync_driver.runSync` (sans-IO §3.7 loop), and /// `LogStore` (persistence). The URL is used verbatim (sync §4 r1 — the client /// never appends /xfer). All-or-nothing failures surface an honest exit-1 /// diagnostic; a bad URL scheme is rejected before any repo is created. fn runClone( 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.parseCloneArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "clone", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "clone", .extra_argument, tok), .no_url => return cli.usageError(streams, "clone", .no_url, ""), .no_dir => return cli.usageError(streams, "clone", .no_dir, ""), }; if (parsed.help) { streams.out.writeAll(clone_help) catch {}; return .ok; } // Open the transport first: a malformed URL fails before any repo lands on // disk (nothing half-made to roll back). `open` performs no network I/O. var tr = transport.Transport.open(io, gpa, parsed.url) catch |e| return syncOpenFail(streams, "clone", parsed.url, e); defer tr.close(); // Create a fresh `.zit` at DIR by reusing the init sequence, quietly (its // own success line is suppressed; clone prints its own). A pre-existing repo // there is init's "already exists" error, propagated verbatim. const init_rc = runInit(gpa, io, arena, &.{ "-q", "--", parsed.dir }, streams); if (init_rc != .ok) return init_rc; // Converge an in-memory store against the peer, then persist it into DIR. var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const outcome = cloneRun(gpa, &tr, &ms) catch |e| return syncRunFail(streams, "clone", e); var target = std.Io.Dir.cwd().openDir(io, parsed.dir, .{}) catch return renderStorage(streams, parsed.dir, "cannot reopen the clone target directory"); defer target.close(io); const written = persistStore(gpa, io, target, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "clone"), error.Storage => return renderStorage(streams, parsed.dir, "cannot write the cloned repository"), error.Corrupt => return renderStorage(streams, parsed.dir, "the peer sent a malformed artifact name"), }; // §9.1 — adopt the origin's project code as this repository's shared // identity (a later pull refuses a PROJECTCODE mismatch), overwriting the // local code `init` seeded (last config frame wins). A peer that sent no // push card leaves the local code in place. storeClonedProjectCode(gpa, io, target, outcome.projectCode()) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "clone"), error.Storage => return renderStorage(streams, parsed.dir, "cannot write the cloned repository"), error.Corrupt => return renderStorage(streams, parsed.dir, "the cloned repository config is unreadable"), }; // Eagerly build the derivation cache so the FIRST read verb after this clone // (`leaves`/`log`/…) takes the zero-parse image path instead of paying the // full cold derive. Run after the project-code frame above — the clone's last // log append — so the cache keys to the settled log. Best-effort (see // `Repository.warmCache`): a failure here leaves the clone correct, with the // first read deriving lazily as it did before; it never fails the clone. Repository.warmCache(gpa, io, target); streams.out.print("cloned {d} artifacts into {s} in {d} rounds\n", .{ written, parsed.dir, outcome.rounds }) catch {}; return .ok; } /// The fields a `updateConfig` overlay may change; a null field is left exactly /// as the stored config has it. Naming a single change site keeps the "copy the /// whole config, overlay one field" discipline in one place — a caller that /// means to change only the project code can never silently drop the credential /// or the saved remote (the class of bug the pre-`updateConfig` re-puts had). const ConfigPatch = struct { project_code: ?[]const u8 = null, login: ?zit.sync.Credential = null, remote_url: ?[]const u8 = null, }; /// Read the live repo config at `dir/.zit`, overlay `patch`, and append the /// result as the new live config (§E last-frame-wins), preserving every field /// the patch does not name. This is the SOLE read-modify-write of the repo /// config; storing a clone's project code, a push credential, or a saved remote /// all route through it. The repo must already carry a config frame (`init` /// always writes one); its absence is `Corrupt`. Shares `PersistError` with the /// artifact-write path so the caller maps one storage-diagnostic surface. fn updateConfig( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, patch: ConfigPatch, ) PersistError!void { var file = dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, }; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, }) orelse return error.Corrupt; // init always writes a config frame defer oc.deinit(gpa); // Copy every field, then overlay only the named ones. `next`'s string slices // borrow `oc.backing` / `patch`; both outlive the `putConfig` memcpy below. var next = oc.config; if (patch.project_code) |c| next.project_code = c; if (patch.login) |cred| { next.login_user = cred.user; // Point at the function-scoped `patch` storage, not the block-local // capture `cred`: `next` is read by `putConfig` after this block closes. next.login_secret_hex = &patch.login.?.secret_hex; } if (patch.remote_url) |u| next.remote_url = u; log.putConfig(next) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; log.commit() catch return error.Storage; } /// §9.1 — persist the origin's project code into the freshly cloned repo at /// `dir/.zit`, preserving every other field `init` wrote. A no-op when the peer /// sent no code. Thin wrapper over `updateConfig` (the field-preserving RMW). fn storeClonedProjectCode( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, project_code: []const u8, ) PersistError!void { if (project_code.len == 0) return; // peer sent none; keep the local init code return updateConfig(gpa, io, dir, .{ .project_code = project_code }); } /// Drives a modern-clone client (§3.8 CLONE_ROUND0 → EXCHANGE) to convergence /// over `tr`, filling `ms` with the peer's artifacts; returns the round count. /// Sans-IO on this side of `tr.exchange`: the state machine only moves /// `[]const u8` bytes. Its closed error set is the driver's own causes unioned /// with the transport's, so `runClone` switches the whole surface exhaustively. /// The result of a clone run: the round count plus the origin's project code /// (§9.1), copied out of the sans-IO client so the caller can persist the /// shared identity after the client is torn down. const CloneOutcome = struct { rounds: usize, project_code_buf: [zit.sync.max_projectcode_len]u8, project_code_len: usize, fn projectCode(self: *const CloneOutcome) []const u8 { return self.project_code_buf[0..self.project_code_len]; } }; fn cloneRun( gpa: std.mem.Allocator, tr: *transport.Transport, ms: *repostate.MemStore, ) sync_driver.RunError(*transport.Transport)!CloneOutcome { var client = zit.sync.Client{ .state = zit.sync.initialState(.clone), .operation = .clone, .self_servercode = sync_servercode, .project_code = sync_projectcode, .version = .{ .version = sync_client_version }, }; defer client.deinit(gpa); const stats = try sync_driver.runSync(gpa, &client, ms.store(), tr, max_sync_rounds); // §8.3/§9.1 — the peer's project code, learned from the clone reply's // `push` card, copied out before the client is deinitialized. var outcome = CloneOutcome{ .rounds = stats.rounds, .project_code_buf = undefined, .project_code_len = 0 }; const pc = client.peerProjectCode(); @memcpy(outcome.project_code_buf[0..pc.len], pc); outcome.project_code_len = pc.len; return outcome; } /// The closed error set of `persistStore`: memory, a storage write fault, or a /// corrupt/oversized peer artifact name that will not decode to a log key. const PersistError = error{ OutOfMemory, Storage, Corrupt }; /// The disposition of one artifact in `persistStore`'s ordered pass. const PersistOutcome = union(enum) { /// A non-phantom frame for this artifact now exists in the log — written /// here, or already present from a prior cascade / a pull re-persist. persisted, /// A reverse-delta whose basis is a content-bearing store entry not YET /// written to the log. It is deferred on the basis wire-hash and re-driven /// once that basis lands, so its `delta_of` offset points strictly backward. deferred: []const u8, }; /// Persist one artifact `rid` into `log`, choosing its stored form, and verify /// §7.7 integrity (the reconstructed content MUST hash to its wire name) BEFORE /// any durable write. A retained delta whose basis is already a held log frame /// under the read depth bound is stored VERBATIM as a `.delta`; a retained delta /// whose basis is a not-yet-written content-bearing store entry is DEFERRED /// (when `allow_defer`) so the basis frame is written first (reverse-delta /// ordering); every other case — no wire form, a full container, or an /// absent / phantom / over-deep basis — lands as the full form, never a /// phantom-based or unreadable delta. Idempotent: an artifact already holding a /// non-phantom frame returns `.persisted` without a re-write or re-verification /// (§7.7 was proven when it was first persisted). fn persistArtifact( gpa: std.mem.Allocator, ms: *repostate.MemStore, log: *logstore.LogStore, rid: repostate.Rid, allow_defer: bool, /// A bounded plaintext memo shared across `persistStore`'s whole sweep. Each /// artifact reconstructs its own plaintext exactly once here — for the §7.7 /// verify — and primes it under its just-written frame's offset, so a later /// reverse-delta naming this frame as basis reuses that plaintext instead of /// re-walking (re-inflating, re-applying) the chain. Bounded (LRU, fixed byte /// + entry caps): a cache miss just costs a fresh reconstruction, never a /// wrong answer, so the stored bytes are identical with or without it. cache: *logstore.PlaintextCache, ) PersistError!PersistOutcome { const store = ms.store(); const name = store.hashOf(rid); var digest_buf: [digest_len]u8 = undefined; const digest = decodeWireName(name, &digest_buf) orelse return error.Corrupt; if (log.lookup(digest)) |ex| { if (!log.isPhantom(ex)) return .persisted; } // A genuine phantom carries nothing durable — skip it (never a drop of held // content). `held` classifies without inflating a wire-backed entry. if (!store.held(rid)) return .persisted; // §7.7 L1332/L1336-1338 — the repository transaction-commit hook. Recompute // the artifact hash over the reconstructed bytes and abort BEFORE any frame is // written on a mismatch (a lying container inflates but does not name its // content). The verify plaintext is reconstructed against the LOG basis (a // read bounded by `max_delta_chain`) — NOT the in-memory wire chain, which, // now a clone stores every artifact wire-backed, is the full reverse-delta // history and would exceed the read depth bound. `persistStore` writes basis // frames before the deltas that name them (reverse-delta order), so a delta's // basis is already a log frame here. if (store.wire(rid)) |wf| { if (wf.delta) |d| { var base_buf: [digest_len]u8 = undefined; const base_digest = decodeWireName(d.src_hash, &base_buf) orelse return error.Corrupt; const maybe_base = log.lookup(base_digest); if (maybe_base == null or log.isPhantom(maybe_base.?)) { // The basis is not yet a log frame. Defer on it (reverse-delta // order: the newer basis frame must be written first) when it is a // held store entry that WILL be persisted. if (allow_defer) { if (store.lookup(d.src_hash)) |bs_rid| { if (store.held(bs_rid)) return .{ .deferred = d.src_hash }; } } // Basis absent and not deferrable (§3.3): fall back to FULL form // from the entry's OWN plaintext — a put+retain entry carries it // directly (never a phantom-based delta). In a converged clone this // is unreachable (every delta's basis is a written frame, handled // below); a wire-delta entry whose basis truly never arrived cannot // reconstruct — a hard error, never a fabricated artifact. const content = store.content(rid) orelse return error.Corrupt; if (!zit.artifact.verify(name, content)) return error.Corrupt; const new_rid = log.putZlib(digest, content) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; try cache.put(gpa, log.offsetOf(new_rid), content); return .persisted; } const base_rid = maybe_base.?; const base_off = log.offsetOf(base_rid); // Reuse the basis plaintext memoised when the basis frame was // persisted — reverse-delta order writes every basis before the // delta that names it, so the memo is normally warm. Only a miss // (first use, or an LRU eviction) reconstructs it by walking the // chain; either way `base_plain` is owned and freed below. const base_plain = if (cache.get(base_off)) |hit| blk: { // §7.7 positive space: a memo entry is the exact plaintext that // named its wire hash at the basis's own persist, so the reused // bytes still hash to the basis name (debug-only assertion; the // hash is never recomputed on the hot path). std.debug.assert(zit.artifact.verify(d.src_hash, hit)); break :blk gpa.dupe(u8, hit) catch return error.OutOfMemory; } else blk: { const p = (log.contentCached(gpa, base_rid, cache) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, }) orelse return error.Corrupt; // `contentCached` does not memoise a top-level full-form basis; // prime it here so sibling reverse-deltas on the same basis reuse // this reconstruction instead of repeating it. try cache.put(gpa, base_off, p); break :blk p; }; defer gpa.free(base_plain); const raw = zit.container.decode(gpa, wf.container, @intCast(logstore.max_payload)) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, }; defer gpa.free(raw); const content = zit.delta.apply(gpa, base_plain, raw) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, }; defer gpa.free(content); if (!zit.artifact.verify(name, content)) return error.Corrupt; // Store the delta VERBATIM iff its basis is a held frame under the READ // depth bound; an over-deep basis falls back to full form (never an // unreadable chain). `delta_of` is the basis's backward offset — // strictly lower than this new frame, the invariant `putDeltaContainer` // asserts. if (log.depthOf(base_rid) < logstore.max_delta_chain) { const new_rid = log.putDeltaContainer(digest, base_rid, wf.container) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; // Prime the just-verified plaintext so an older reverse-delta // naming this frame as basis reuses it (no chain re-walk). try cache.put(gpa, log.offsetOf(new_rid), content); } else { const new_rid = log.putZlib(digest, content) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; try cache.put(gpa, log.offsetOf(new_rid), content); } return .persisted; } // §7.2 full form — decode to verify, then store the container verbatim. const content = zit.container.decode(gpa, wf.container, @intCast(logstore.max_payload)) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, }; defer gpa.free(content); if (!zit.artifact.verify(name, content)) return error.Corrupt; const new_rid = log.putContainer(digest, wf.container) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; // Prime the just-verified full-form plaintext so a reverse-delta naming // this frame as basis reuses it instead of re-inflating the container. try cache.put(gpa, log.offsetOf(new_rid), content); return .persisted; } // §4.1 — no retained wire (a raw pull `put`): the plaintext is memoised in the // store, no chain to walk. Verify and compress it; the hash names the // UNCOMPRESSED bytes, so compression is address-transparent. const content = store.content(rid) orelse return error.Corrupt; if (!zit.artifact.verify(name, content)) return error.Corrupt; const new_rid = log.putZlib(digest, content) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, }; // Prime the plaintext so a reverse-delta naming this frame as basis reuses it. try cache.put(gpa, log.offsetOf(new_rid), content); return .persisted; } /// Persists every content-bearing artifact in the converged store `ms` into the /// on-disk log at `dir/.zit`, then fsyncs (durability before success). The /// store names artifacts by their wire hex; the log keys frames by the raw /// binary digest, so each name is decoded once here — the inverse of the /// hex-encode in `loadContentStore`. Idempotent per `LogStore.put` (an artifact /// already held returns its rid without a re-append), so a pull re-persisting a /// store that mixes prior holdings with freshly received content writes only the /// new frames. Returns the number of content-bearing artifacts in the store. /// Shared by clone (fresh repo) and pull (existing repo). fn persistStore( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, ms: *repostate.MemStore, ) PersistError!usize { var file = dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); // The header of the just-created repo carries the hash kind; `open` reads it // from the non-empty file, so the passed value is a don't-care (0). var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, }; defer log.deinit(gpa); var written: usize = 0; const store = ms.store(); const n = store.count(); // Reverse-delta persist ordering (§7.2/§8.2). Fossil stores an OLDER // artifact as a delta of a NEWER one, so a delta cfile can land in the store // at a LOWER rid than the basis it points at, while the log's `delta_of` is a // BACKWARD offset that REQUIRES the basis frame to precede the delta. Persist // therefore drains a worklist: a delta whose basis is not yet a frame is // deferred (keyed by the basis wire-hash) and re-driven the instant that // basis lands. Each content-bearing artifact seeds the drain once in rid // order; each deferred delta is re-driven exactly once, so the pass is O(n) // and terminates — a chain bottoms out at fossil's full newest artifact, and // content addressing forbids a cycle. var waiting: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(repostate.Rid)) = .empty; defer { var it = waiting.valueIterator(); while (it.next()) |list| list.deinit(gpa); waiting.deinit(gpa); } var ready: std.ArrayListUnmanaged(repostate.Rid) = .empty; defer ready.deinit(gpa); // One bounded plaintext memo for the whole sweep: a basis reconstructed for // its own §7.7 verify is reused by every reverse-delta that names it, so a // delta family reconstructs each basis ~once rather than once per referencing // delta. Bounded (LRU byte + entry caps) — it never retains the whole repo. var cache = logstore.PlaintextCache.init(); defer cache.deinit(gpa); var seed: repostate.Rid = 1; while (seed <= n) : (seed += 1) { if (!store.held(seed)) continue; // residual phantom: not durable written += 1; // count each content-bearing artifact exactly once, at its rid try ready.append(gpa, seed); while (ready.pop()) |rid| { switch (try persistArtifact(gpa, ms, &log, rid, true, &cache)) { .persisted => { // `rid`'s frame now exists: re-drive any reverse-deltas that // deferred on it as their (now-written) basis. if (waiting.fetchRemove(store.hashOf(rid))) |kv| { var list = kv.value; defer list.deinit(gpa); try ready.appendSlice(gpa, list.items); } }, .deferred => |base_hash| { const gop = try waiting.getOrPut(gpa, base_hash); if (!gop.found_existing) gop.value_ptr.* = .empty; try gop.value_ptr.append(gpa, rid); }, } } } // Defensive drain: a deferred basis is itself content-bearing and therefore // persisted above (draining its waiters), so `waiting` is normally empty // here. Should a basis nonetheless be missing, its waiters land as full form // rather than be dropped — every content-bearing artifact stays durable. var leftover = waiting.iterator(); while (leftover.next()) |e| { for (e.value_ptr.items) |rid| { switch (try persistArtifact(gpa, ms, &log, rid, false, &cache)) { .persisted => {}, .deferred => unreachable, // allow_defer=false never defers } } } log.commit() catch return error.Storage; return written; } // --------------------------------------------------------------------------- // `zit pack` — repack the store into a compact new generation. // // On-disk storage is UNSPECIFIED by the RFCs (they fix the wire format and // content-addressed naming, §4.1 L1206-1208), so pack is free to reorganize // the log as long as every artifact's exact content and name survive and the // §7.2 verbatim wire bytes are still servable. It re-ingests every artifact, // in the source's offset order, into a FRESH log through `putArtifact` with a // lineage base hint — a check-in against its parent manifest, a file blob // against the same path's blob in the parent check-in (the commit path's own // §4.1/§7.2 compaction heuristic, applied offline). Offset order plus the // strictly-backward `delta_of` invariant means every base is already written // before the delta that names it, so no worklist is needed (unlike // `persistStore`, whose input is rid-ordered). // // The new log is written to a temporary file beside `.zit`, fsynced, and then // atomically renamed over `.zit`: a crash anywhere before the rename leaves the // original byte-identical (the temp is discarded next run). The `.zit-cache` // invalidates itself on the tail change and rebuilds on the next read verb. // --------------------------------------------------------------------------- /// pack's closed error set: memory, a destination write fault, or a source /// artifact whose reconstructed content does not hash to its name (§7.7) or /// whose frame is otherwise corrupt. const PackError = error{ OutOfMemory, Storage, Corrupt }; /// One repack's tally: the artifact count (== the source's content frames), how /// many landed as deltas in the new log, and the on-disk sizes before/after. const PackStats = struct { artifacts: usize, deltas: usize, bytes_before: u64, bytes_after: u64, }; /// The name of pack's transient new-generation file, beside `.zit`. A fixed /// name (never a random one) so a temp left by a crashed pack is simply /// overwritten by the next run rather than accumulating. Sourced from /// `extras.zig`, which owns the literal for the single never-tracked predicate. const pack_tmp_filename = extras.pack_tmp_filename; /// Repack every artifact from `src` into the fresh, empty `dst` (same /// `hash_kind`), MONOTONICALLY: each frame is copied into `dst` at no larger /// size than it occupies in `src`, so the packed log is never bigger than the /// source. The default is verbatim preservation — a `.delta` frame's §4.1 /// container is copied byte-for-byte and re-linked to its base's new rid, a /// `.zlib` (or test-only `.raw`) full-form frame's payload is copied as-is — so /// every wire-delivered delta survives intact (this is the whole storage-safety /// property: pack keeps what is already compact rather than reconstructing it to /// full form). A full-form frame is re-deltaed against a lineage-base hint ONLY /// when the resulting delta frame is strictly smaller (`putDeltaIfSmaller`); /// given zit's weak base selection this rarely fires, and it can never inflate. /// Every reconstruction is §7.7-verified before its frame is written. `dst` MUST /// be a just-opened empty log; `src` is never written. Deterministic: fixed /// offset-order iteration, a fixed lineage hint, and the fixed size test make /// the packed bytes a pure function of the source content — so `pack∘pack` /// reproduces the same log. fn packLog(gpa: std.mem.Allocator, src: *logstore.LogStore, dst: *logstore.LogStore) PackError!PackStats { // The live config survives as exactly one frame; every superseded config // record is dropped (the whole of pack's "dead frame" reclaim, §E). if (try readSrcConfig(gpa, src)) |oc_val| { var owned = oc_val; defer owned.deinit(gpa); dst.putConfig(owned.config) catch |e| return storagePutError(e); } // Artifacts in offset order: a delta's base always precedes it, so every // base is already written into `dst` before the delta that names it and // every hint resolves against an already-written frame or falls back to full. const rids = try src.ridsInPersistedOrder(gpa); defer gpa.free(rids); // Lineage base hints (source rid → source base rid), derived from a first // pass over the manifests. Absent ⇒ no hint ⇒ verbatim full form. Only the // optional strictly-smaller re-delta consults these. var hints: std.AutoHashMapUnmanaged(logstore.Rid, logstore.Rid) = .empty; defer hints.deinit(gpa); try deriveLineageHints(gpa, src, rids, &hints); var cache = logstore.PlaintextCache.init(); defer cache.deinit(gpa); var deltas: usize = 0; for (rids) |src_rid| { // Non-phantom by construction (ridsInPersistedOrder filters phantoms), // so content is never null. const content = (try srcContent(gpa, src, src_rid, &cache)) orelse continue; defer gpa.free(content); const digest = src.hashOf(src_rid); // §7.7 L1332/L1336-1338 — the reconstructed bytes MUST hash to the // artifact's name, recomputed BEFORE any frame is written. A lying frame // aborts the pack with the original store untouched. var name_buf: [2 * logstore.max_hash_len]u8 = undefined; const name = digestToWireName(digest, &name_buf); if (!zit.artifact.verify(name, content)) return error.Corrupt; if (try packFrame(gpa, src, dst, src_rid, digest, content, &hints)) deltas += 1; } dst.commit() catch return error.Storage; // fsync the new log before the caller renames it return .{ .artifacts = rids.len, .deltas = deltas, .bytes_before = src.size(), .bytes_after = dst.size(), }; } /// Copy the already-verified artifact `src_rid` (content `content`, key /// `digest`) into `dst` at no larger size than it occupies in `src`, and report /// whether it landed as a `.delta` frame. A `.delta` source is re-served /// verbatim: its stored §4.1 container is copied byte-for-byte and re-linked to /// its base's `dst` rid (the base precedes it in offset order, so it is already /// written — asserted). A full-form (`.zlib`/`.raw`) source is re-deltaed /// against its lineage hint ONLY when that yields a strictly smaller frame, else /// its payload is preserved verbatim (`putContainer` keeps a `.zlib` wire /// container intact for §7.2 re-serve; `.raw` — test-only — is copied as raw). /// Either branch keeps `dst`'s frame ≤ `src`'s frame: the monotonic guarantee. fn packFrame( gpa: std.mem.Allocator, src: *logstore.LogStore, dst: *logstore.LogStore, src_rid: logstore.Rid, digest: []const u8, content: []const u8, hints: *const std.AutoHashMapUnmanaged(logstore.Rid, logstore.Rid), ) PackError!bool { switch (src.kindOf(src_rid)) { .delta => { var w = (try srcWire(gpa, src, src_rid)) orelse return error.Corrupt; // a .delta frame always has wire defer w.deinit(gpa); const d = w.delta; // The base precedes this delta in offset order and every prior // artifact frame was copied, so it is already in `dst`. A missing // base would mean a source we admitted is internally inconsistent // (its content nonetheless reconstructed), so abort rather than // inflate or drop — never reached in a well-formed store. const base_rid = dst.lookup(d.baseHash()) orelse return error.Corrupt; _ = dst.putDeltaContainer(digest, base_rid, d.container) catch |e| return storagePutError(e); return true; }, .zlib, .raw => { // Optional improvement: re-delta against the lineage hint, but only // if the delta frame is strictly smaller than the verbatim payload. const full_len = src.payloadLenOf(src_rid); if (hints.get(src_rid)) |src_base| { if (dst.lookup(src.hashOf(src_base))) |base_rid| { if (dst.putDeltaIfSmaller(digest, content, base_rid, full_len) catch |e| return storagePutError(e)) |_| { return true; } } } // Verbatim full form. A `.zlib` wire container is preserved intact // (§7.2); a `.raw` source (only synthetic test stores hold one) is // copied as raw — `content` IS its payload. if (src.kindOf(src_rid) == .zlib) { var w = (try srcWire(gpa, src, src_rid)) orelse return error.Corrupt; // a .zlib frame always has wire defer w.deinit(gpa); _ = dst.putContainer(digest, w.full) catch |e| return storagePutError(e); } else { _ = dst.put(digest, content) catch |e| return storagePutError(e); } return false; }, // Config is never an interned artifact rid, so it never appears in // `ridsInPersistedOrder`; any other kind is rejected at open. .config, _ => unreachable, } } /// `wire` narrowed to pack's error set. fn srcWire(gpa: std.mem.Allocator, src: *logstore.LogStore, rid: logstore.Rid) PackError!?logstore.LogStore.Wire { return src.wire(gpa, rid) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } /// `src.readConfig` narrowed to pack's error set (a config frame that will not /// decode is `Corrupt`, mirroring §7.7's abort-on-corrupt discipline). fn readSrcConfig(gpa: std.mem.Allocator, src: *logstore.LogStore) PackError!?logstore.OwnedConfig { return src.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } /// `contentCached` narrowed to pack's error set. fn srcContent(gpa: std.mem.Allocator, src: *logstore.LogStore, rid: logstore.Rid, cache: *logstore.PlaintextCache) PackError!?[]u8 { return src.contentCached(gpa, rid, cache) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } /// Map a `putArtifact`/`putConfig` failure into pack's error set. fn storagePutError(e: logstore.PutError) PackError { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, }; } /// Walk the source manifests in offset order, filling `hints` with the lineage /// base for each artifact: a check-in against its primary parent manifest, and /// each file blob against the same path's blob in the parent check-in. First /// assigner wins (a blob referenced by several check-ins keeps the lowest-offset /// hint), so the result is deterministic. A non-manifest artifact simply /// contributes no hint. Only memory/storage/corrupt frame faults escape. fn deriveLineageHints( gpa: std.mem.Allocator, src: *logstore.LogStore, rids: []const logstore.Rid, hints: *std.AutoHashMapUnmanaged(logstore.Rid, logstore.Rid), ) PackError!void { var cache = logstore.PlaintextCache.init(); defer cache.deinit(gpa); for (rids) |m_rid| { const bytes = (try srcContent(gpa, src, m_rid, &cache)) orelse continue; defer gpa.free(bytes); // Only a check-in manifest carries F/P lineage; a blob (or a control // artifact) fails the parse or is a different kind and contributes none. // Read path: pack reads stored (fossil-origin) manifests for lineage, // so it tolerates the legacy F-card trailing separator. var loaded = zit.artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => continue, }; defer loaded.deinit(gpa); const m = &loaded.manifest; if (m.kind != .manifest) continue; const parent_rid = primaryParentRid(src, m) orelse continue; // (a) manifest → parent manifest. A manifest rid is unique, so the put // never collides; the parent frame precedes this one in offset order. try hints.put(gpa, m_rid, parent_rid); // (b) each F-card blob → the same path's blob in the parent check-in. try addBlobHints(gpa, src, hints, m, parent_rid); } } /// The log rid of a manifest's primary (first) parent, or null when it is a /// root or the parent is not held. `parents` is the space-separated P-card /// argument (artifact §4.7.13); the first token is the merge-primary parent. fn primaryParentRid(src: *logstore.LogStore, m: *const zit.artifact.Manifest) ?logstore.Rid { if (m.p.len == 0) return null; var it = m.p[0].hashes(); const first = it.next() orelse return null; const digest = hexToDigest(first) orelse return null; return src.lookup(&digest); } /// For each file blob named by check-in `m`, record the same path's parent blob /// as its delta base (`hints[child_blob] = parent_blob`), reconstructing the /// parent manifest on demand to read its F-set. An unchanged file (same blob in /// both) contributes no hint. First assigner wins. fn addBlobHints( gpa: std.mem.Allocator, src: *logstore.LogStore, hints: *std.AutoHashMapUnmanaged(logstore.Rid, logstore.Rid), m: *const zit.artifact.Manifest, parent_rid: logstore.Rid, ) PackError!void { if (m.f.len == 0) return; const p_bytes = (try packContent(gpa, src, parent_rid)) orelse return; defer gpa.free(p_bytes); // Read path: reconstructing the parent's F-set from stored (fossil-origin) // history tolerates the legacy F-card trailing separator. var loaded = zit.artifact.parseForImport(gpa, p_bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return, // a parent that will not parse simply yields no blob hints }; defer loaded.deinit(gpa); const pm = &loaded.manifest; if (pm.kind != .manifest) return; // Parent path → blob rid. Keys borrow `pm`'s arena, valid for this scope. var parent_blob = std.StringHashMapUnmanaged(logstore.Rid).empty; defer parent_blob.deinit(gpa); for (pm.f) |fc| { const h = fc.hash orelse continue; const d = hexToDigest(h) orelse continue; const brid = src.lookup(&d) orelse continue; try parent_blob.put(gpa, fc.filename, brid); } for (m.f) |fc| { const h = fc.hash orelse continue; const d = hexToDigest(h) orelse continue; const child_blob = src.lookup(&d) orelse continue; const base_blob = parent_blob.get(fc.filename) orelse continue; if (base_blob == child_blob) continue; // unchanged file: one shared frame, no delta const gop = try hints.getOrPut(gpa, child_blob); if (!gop.found_existing) gop.value_ptr.* = base_blob; } } /// `content` narrowed to pack's error set (used where no sweep cache is held). fn packContent(gpa: std.mem.Allocator, src: *logstore.LogStore, rid: logstore.Rid) PackError!?[]u8 { return src.content(gpa, rid) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } /// `zit pack` — repack the current repository's store into a compact new /// generation and atomically swap it in. Thin CLI wiring over `packLog`: parse /// (no positional), discover the checkout root, open `.zit` read-only (which /// whole-frame-CRC-verifies every frame — the integrity pre-flight) and capture /// its tail key, stream the packed log into a temp file, fsync, re-check the /// source tail (the no-repo-lock concurrency guard), then rename the temp over /// `.zit`. On any failure the temp is deleted and the original store is intact. fn runPack( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "pack", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "pack", .extra_argument, tok), }; if (help) { streams.out.writeAll(pack_help) catch {}; return .ok; } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "pack")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // Open the source read-only: `openReadOnly` scans and whole-frame-CRC-checks // every frame, so a corrupt source aborts here (no temp written). The tail // key pins the log we packed for the pre-rename concurrency re-check. var src_file = root_dir.openFile(io, repo_filename, .{}) catch return repoReadFail(streams, "pack"); defer src_file.close(io); const src_st = src_file.stat(io) catch return repoReadFail(streams, "pack"); var src_fs = storage.FileStorage.init(io, src_file, src_st.size); var src = logstore.LogStore.openReadOnly(gpa, src_fs.storageFile()) catch |e| { switch (e) { error.OutOfMemory => streams.err.writeAll("zit pack: out of memory\n") catch {}, else => streams.err.print("zit pack: cannot read repository: {s}\n", .{reasonOf(e)}) catch {}, } return .failure; }; defer src.deinit(gpa); const src_key = src.logKey() catch return packFail(streams, error.Corrupt); // New-generation file: a readable temp beside `.zit` (createFileAtomic opens // write-only, but pack reads its own delta bases back, so we roll our own // readable temp and rename it). `truncate` overwrites a temp left by a prior // crashed pack. Cleaned up on every path except the successful rename. var tmp_file = root_dir.createFile(io, pack_tmp_filename, .{ .read = true, .truncate = true }) catch return packFail(streams, error.Storage); var renamed = false; defer { tmp_file.close(io); if (!renamed) root_dir.deleteFile(io, pack_tmp_filename) catch {}; } var dst_fs = storage.FileStorage.init(io, tmp_file, 0); var dst = logstore.LogStore.open(gpa, dst_fs.storageFile(), src.hash_kind) catch |e| switch (e) { error.OutOfMemory => return packFail(streams, error.OutOfMemory), error.Storage => return packFail(streams, error.Storage), error.Corrupt => return packFail(streams, error.Corrupt), }; const stats = packLog(gpa, &src, &dst) catch |e| { dst.deinit(gpa); return packFail(streams, e); }; dst.deinit(gpa); // `packLog` already fsynced; deinit only frees memory // No repo lock exists: if the source log changed under us since we scanned // it, abort rather than clobber a concurrent write with our stale snapshot. // O(1) (one stat + one header read); the original stays intact. if (!logstore.LogStore.validateTail(src_fs.storageFile(), src_key)) { streams.err.writeAll( "zit pack: repository changed during pack; aborted (original is intact)\n", ) catch {}; return .failure; } // The single durable transition: atomically replace `.zit` with the packed // log. A crash before this leaves the original untouched; after it, the // complete new log. There is no window where `.zit` is partial. root_dir.rename(pack_tmp_filename, root_dir, repo_filename, io) catch return packFail(streams, error.Storage); renamed = true; renderPackStats(streams.out, stats); return .ok; } /// Render pack's failure `err` as `zit pack: <reason>` on stderr, exit 1. The /// original store is always intact when this runs (every write went to the /// temp, which the caller's `defer` deletes). fn packFail(s: cli.Streams, err: PackError) cli.Exit { s.err.print("zit pack: {s}\n", .{reasonOf(err)}) catch {}; return .failure; } /// One human line summarizing a repack. "already packed" when the new log did /// not shrink (the store was already compact — pack still verified every frame, /// its integrity-scrub value). Split from `runPack` so the exact wording is unit /// tested without a filesystem. fn renderPackStats(out: *std.Io.Writer, stats: PackStats) void { const verb = if (stats.bytes_after < stats.bytes_before) "packed" else "already packed"; out.print("{s}: {d} artifacts, {d} re-deltaed, {d} -> {d} bytes\n", .{ verb, stats.artifacts, stats.deltas, stats.bytes_before, stats.bytes_after, }) catch {}; } /// Maps a transport-open failure (bad URL) to the `verb` diagnostic + exit 1. /// Shared by clone and pull — the only difference is the verb label. fn syncOpenFail(s: cli.Streams, verb: []const u8, url: []const u8, err: transport.TransportError) cli.Exit { switch (err) { error.InvalidUrl => s.err.print("zit {s}: '{s}' is not a valid URL\n", .{ verb, url }) catch {}, error.UnsupportedScheme => s.err.print("zit {s}: unsupported URL scheme in '{s}' (use http or https)\n", .{ verb, url }) catch {}, error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, else => s.err.print("zit {s}: cannot open '{s}'\n", .{ verb, url }) catch {}, } return .failure; } /// Maps a sync run (transport + driver) failure to the `verb` diagnostic + exit /// 1. Exhaustive over the driver's own causes and the transport's, so the whole /// closed error set is accounted for (no `else`). Shared by clone and pull. fn syncRunFail(s: cli.Streams, verb: []const u8, err: sync_driver.RunError(*transport.Transport)) cli.Exit { const reason: []const u8 = switch (err) { error.OutOfMemory => "out of memory", error.RoundLimitExceeded => "the peer did not converge within the round limit", error.FatalProtocolError => "the peer sent a fatal protocol error", error.NotImplemented => "the peer used an unsupported protocol feature", error.RequestTooLarge => "the sync request exceeded the transport limit", error.ReplyTruncated, error.ReplyMalformed => "the peer sent a malformed reply", error.ReplyTooLarge => "the peer's reply exceeded the size limit", error.RedirectLimit => "too many HTTP redirects", error.InsecureRedirect => "the peer redirected HTTPS to plaintext HTTP", error.RedirectNotAllowed => "the peer redirected on a transport that forbids it", error.MissingLocation => "the peer sent a redirect with no location", error.InvalidUrl => "a redirect target was not a valid URL", error.UnsupportedScheme => "a redirect named an unsupported URL scheme", error.ProtocolError => "the peer answered with an unexpected HTTP status", error.ConnectionFailed => "cannot connect to the peer", }; s.err.print("zit {s}: {s}\n", .{ verb, reason }) catch {}; return .failure; } /// The causes `resolveRemoteUrl` can surface: a store read failure, OOM, or the /// repository having no URL to use (`NoRemote` — neither an explicit arg nor a /// saved remote). Closed set so the caller switches exhaustively. const RemoteResolveError = error{ OutOfMemory, Storage, NoRemote }; /// sync §4/§6 — resolve the peer URL for a sync-family verb: an explicit `arg` /// (non-empty) wins; otherwise the saved remote (`config.remote_url`) is used; /// a repository with neither is a usage error (`NoRemote`). The saved URL is /// copied into `buf` so it outlives the read-only config frame. `buf` need only /// live until `transport.Transport.open` (which dupes the URL). An out-of-spec /// stored URL longer than the config-field cap is treated as none. fn resolveRemoteUrl( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, arg: []const u8, buf: *[logstore.max_config_str]u8, ) RemoteResolveError![]const u8 { if (arg.len > 0) return arg; var file = dir.openFile(io, repo_filename, .{}) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }) orelse return error.NoRemote; defer oc.deinit(gpa); const saved = oc.config.remote_url; if (saved.len == 0 or saved.len > buf.len) return error.NoRemote; @memcpy(buf[0..saved.len], saved); return buf[0..saved.len]; } /// sync §4/§6 — the usage error (exit 2) when a sync-family `verb` is given no /// URL and the repository has no saved remote to fall back to. Names the fix. /// Shared by pull, push, and sync so the wording lives in one place. fn noRemoteError(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: no repository URL given and no saved remote\n", .{verb}) catch {}; s.err.writeAll("set one with 'zit remote URL', or pass the URL explicitly\n") catch {}; return .usage; } // --------------------------------------------------------------------------- // `zit pull URL` — incremental sync into an EXISTING repository. The sibling of // clone: it reuses the same transport + `runSync` + persist machinery, but opens // the cwd's `.zit` (never creating one), announces what it already holds so the // peer sends only the missing artifacts (sync §3.2/§3.3 igot/gimme), and drives // to §3.7 termination. Receive-only (§3.3 r24: pull obtains but never uploads). // --------------------------------------------------------------------------- /// `zit pull URL` — open the current repository, converge it toward the peer at /// URL, and persist the newly received artifacts. Thin wiring over the same deep /// modules clone uses: `transport` (HTTP/IO), `sync.Client` + `runSync` (sans-IO /// §3.7 loop), and `LogStore` (persistence). With URL omitted the saved remote /// (`zit remote`) is used; a repo with neither is a usage error. Anonymous only. fn runPull( 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.parsePullArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "pull", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "pull", .extra_argument, tok), // An omitted URL is not an error: it falls back to the saved remote below. .no_url => cli.PullArgs{ .url = "" }, }; if (parsed.help) { streams.out.writeAll(pull_help) catch {}; return .ok; } // Locate the current repository before any network I/O: a pull outside a repo // is a usage failure, not a wasted connection. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "pull")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // sync §4/§6 — the explicit URL wins; an omitted one falls back to the saved // remote; a repo with neither is a usage error. `url_buf` outlives `open`. var url_buf: [logstore.max_config_str]u8 = undefined; const url = resolveRemoteUrl(gpa, io, root_dir, parsed.url, &url_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "pull"), error.Storage => return repoReadFail(streams, "pull"), error.NoRemote => return noRemoteError(streams, "pull"), }; var tr = transport.Transport.open(io, gpa, url) catch |e| return syncOpenFail(streams, "pull", url, e); defer tr.close(); // Snapshot what this repository already holds into the in-memory store: those // entries become the client's `igot` announcement, so the peer sends only the // artifacts this side lacks (sync §3.3 — the incremental of the exchange). var ms = repostate.MemStore.init(gpa); defer ms.deinit(); loadRepoHoldings(gpa, io, root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "pull"), error.Storage => return repoReadFail(streams, "pull"), }; const held_before = contentCount(ms.store()); // §9.1 — announce this repository's adopted project code in the pull card; // a real peer refuses a PROJECTCODE mismatch, so the placeholder cannot be // sent against fossil. The buffer outlives the run (the client borrows it). var pc_buf: [zit.sync.max_projectcode_len]u8 = undefined; const project_code = repoProjectCode(gpa, io, root_dir, &pc_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "pull"), error.Storage => return repoReadFail(streams, "pull"), }; const rounds = pullRun(gpa, &tr, &ms, project_code) catch |e| return syncRunFail(streams, "pull", e); const written = persistStore(gpa, io, root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "pull"), error.Storage => return repoReadFail(streams, "pull"), error.Corrupt => return renderStorage(streams, root.abs, "the peer sent a malformed artifact name"), }; // A pull only adds content to what was already held (§3.3 r24: it never drops // an artifact), so the persisted count can never fall below the starting set. std.debug.assert(written >= held_before); const received = written - held_before; if (received == 0) { streams.out.writeAll("already up to date\n") catch {}; } else { streams.out.print("received {d} artifacts in {d} rounds\n", .{ received, rounds }) catch {}; } return .ok; } /// Drives a pull client (§3.2/§3.3) to convergence over `tr`, filling `ms` with /// the artifacts the peer holds and this side lacked; returns the round count. /// The client announces `ms`'s holdings via igot and requests the rest via /// gimme (receive-only — §3.3 r24: pull obtains content but never uploads). /// Sans-IO on this side of `tr.exchange`. Its closed error set is the driver's /// own causes unioned with the transport's, so `runPull` switches it exhaustively. fn pullRun( gpa: std.mem.Allocator, tr: *transport.Transport, ms: *repostate.MemStore, project_code: []const u8, ) sync_driver.RunError(*transport.Transport)!usize { var client = zit.sync.Client{ .state = zit.sync.initialState(.pull), .operation = .pull, .self_servercode = sync_servercode, .project_code = project_code, .version = .{ .version = sync_client_version }, }; defer client.deinit(gpa); const stats = try sync_driver.runSync(gpa, &client, ms.store(), tr, max_sync_rounds); return stats.rounds; } /// Opens `dir/.zit` read-only and snapshots its stored artifacts into `ms` — the /// held/phantom split the pull client announces via `igot` (sync §3.2/§3.3). /// Reuses `loadContentStore` (the very snapshot `serve` builds per request), so /// the binary-log→wire-hex name encoding lives in exactly one place. The /// read-only handle is closed before `persistStore` reopens the log read-write. fn loadRepoHoldings( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, ms: *repostate.MemStore, ) error{ OutOfMemory, Storage }!void { var file = dir.openFile(io, repo_filename, .{}) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; defer log.deinit(gpa); loadContentStore(gpa, &log, ms) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; } /// sync §9.1 — the repository's stored project code, copied into `buf`, or the /// zero placeholder when the repo carries none (a repo predating the stored /// code) or an out-of-spec-length one. `init` derives a project code and a /// clone adopts the origin's (`storeClonedProjectCode`), so a real repo /// announces its adopted code here; the pull/push client emits it verbatim as /// the operation card's PROJECTCODE, and a real peer (fossil §9.1, ABNF /// `pull-card = "pull" SP servercode SP projectcode`) refuses a mismatch — so a /// zero placeholder would be rejected. Bounded by `max_projectcode_len`: a /// longer stored field is out of spec and never truncated into the wire. fn repoProjectCode( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, buf: *[zit.sync.max_projectcode_len]u8, ) error{ OutOfMemory, Storage }![]const u8 { var file = dir.openFile(io, repo_filename, .{}) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }) orelse return sync_projectcode; defer oc.deinit(gpa); const code = oc.config.project_code; if (code.len == 0 or code.len > buf.len) return sync_projectcode; @memcpy(buf[0..code.len], code); return buf[0..code.len]; } /// The number of content-bearing (non-phantom) artifacts in `store` — the held /// set (§3.3 r24). A pull measures its yield as the held count after convergence /// minus the count before, so "already up to date" is an exact zero, never a guess. fn contentCount(store: repostate.ArtifactStore) usize { var n: usize = 0; const total = store.count(); var rid: repostate.Rid = 1; while (rid <= total) : (rid += 1) { if (store.content(rid) != null) n += 1; } return n; } // --------------------------------------------------------------------------- // `zit push URL` — the mirror of pull: UPLOAD this repository's artifacts into an // EXISTING served repo. It opens the cwd's `.zit`, announces what it holds so the // peer requests only what it lacks (sync §3.2/§3.3 igot/gimme), and uploads the // missing artifacts as `file` cards, driving to §3.7 termination. The peer's // `zit serve` persists what it receives; nothing changes on this side (§3.3 r24). // Anonymous only. // --------------------------------------------------------------------------- /// What one push run reports: the number of artifacts uploaded and the round /// count it took. `sent` reads the client's own upload counter, so it is the /// exact count of `file` cards emitted — never an estimate. const PushOutcome = struct { rounds: usize, sent: usize }; /// `zit push URL` — open the current repository, announce its holdings to the /// peer at URL, and upload every artifact the peer lacks until §3.7 termination. /// Thin wiring over the same deep modules pull uses: `transport` (HTTP/IO), /// `sync.Client` + `runSync` (sans-IO §3.7 loop). With URL omitted the saved /// remote (`zit remote`) is used; a repo with neither is a usage error. fn runPush( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parsePushArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "push", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "push", .extra_argument, tok), // An omitted URL is not an error: it falls back to the saved remote below. .no_url => cli.PushArgs{ .url = "" }, }; if (parsed.help) { streams.out.writeAll(push_help) catch {}; return .ok; } // sync §6.3 — a login name is a whitespace-free token (the shared-secret // derivation embeds it between "/" separators). Reject a malformed --user // as a usage failure rather than reaching the derivation's assertion. if (parsed.user) |u| { if (u.len > 0 and zit.sync.containsFossilWhitespace(u)) { streams.err.writeAll("zit push: --user must not contain whitespace\n") catch {}; return .failure; } } // Locate the current repository before any network I/O: a push outside a repo // is a usage failure, not a wasted connection. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "push")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // sync §4/§6 — the explicit URL wins; an omitted one falls back to the saved // remote; a repo with neither is a usage error. `url_buf` outlives `open`. var url_buf: [logstore.max_config_str]u8 = undefined; const url = resolveRemoteUrl(gpa, io, root_dir, parsed.url, &url_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "push"), error.Storage => return repoReadFail(streams, "push"), error.NoRemote => return noRemoteError(streams, "push"), }; var tr = transport.Transport.open(io, gpa, url) catch |e| return syncOpenFail(streams, "push", url, e); defer tr.close(); // Snapshot what this repository holds: those entries become the client's igot // announcement, so the peer requests (and this side uploads) only the artifacts // the peer lacks (sync §3.2/§3.3 — the incremental of the exchange). var ms = repostate.MemStore.init(gpa); defer ms.deinit(); loadRepoHoldings(gpa, io, root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "push"), error.Storage => return repoReadFail(streams, "push"), }; // §9.1 — announce this repository's adopted project code in the push card // (same identity gate as pull; a real peer refuses a mismatch). The buffer // outlives the run — the client borrows it. var pc_buf: [zit.sync.max_projectcode_len]u8 = undefined; const project_code = repoProjectCode(gpa, io, root_dir, &pc_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "push"), error.Storage => return repoReadFail(streams, "push"), }; // sync §6 — resolve the login credential (null for an anonymous push). The // password plaintext is confined to `pw_buf`/argv/env; only the derived // 40-hex secret (§6.3) is signed with and — on success — persisted. var pw_buf: [max_password_len]u8 = undefined; // Defense-in-depth: once the 40-hex secret is derived the plaintext in // pw_buf is spent, so scrub it on every exit from this scope — success and // error alike — rather than leave it resident until the stack is reused. defer std.crypto.secureZero(u8, &pw_buf); const resolved = resolvePushCredential(gpa, io, root_dir, streams, project_code, parsed, amb, &pw_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "push"), error.Storage => return repoReadFail(streams, "push"), error.NoPassword => { streams.err.writeAll("zit push: no password supplied for --user\n") catch {}; return .failure; }, }; const outcome = pushRun(gpa, &tr, &ms, project_code, if (resolved) |r| r.credential else null) catch |e| return syncRunFail(streams, "push", e); // DECISION-3 — persist the derived secret (never the plaintext) at 0600 only // after the peer accepted the authenticated push, and only when it was // freshly derived: a reused stored secret is already durable. if (resolved) |r| { if (r.persist) { storePushCredential(gpa, io, root_dir, r.credential) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "push"), else => return repoReadFail(streams, "push"), }; } } if (outcome.sent == 0) { streams.out.writeAll("nothing to push\n") catch {}; } else { streams.out.print("sent {d} artifacts in {d} rounds\n", .{ outcome.sent, outcome.rounds }) catch {}; } return .ok; } /// The upper bound on an interactively-typed or `$ZIT_PASSWORD` password. A /// login password far shorter than this in practice; the fixed bound keeps the /// no-echo read on the stack with no unbounded allocation (TigerStyle). const max_password_len: usize = 1024; /// sync §6.3 — a resolved push credential and whether its secret was freshly /// derived (so it should be persisted after a successful push). const ResolvedCredential = struct { credential: zit.sync.Credential, persist: bool, }; /// The causes `resolvePushCredential` can surface: store read failure, OOM, or /// a `--user` with no obtainable password (`NoPassword`). Closed set so the /// caller switches exhaustively. const ResolveError = error{ OutOfMemory, Storage, NoPassword }; /// sync §6.3/DECISION-3 — turn `push`'s `--user`/`--password` into a signing /// credential. A null (or empty) `--user` is an anonymous push (null result). /// Otherwise the 40-hex secret is resolved, in order, from: a stored credential /// for this exact login (reused verbatim, already durable), `--password`, /// `$ZIT_PASSWORD`, or a no-echo terminal prompt. A freshly derived secret is /// flagged `persist`; the plaintext lives only in `pw_buf`/argv/env and is /// never returned or stored. fn resolvePushCredential( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, streams: cli.Streams, project_code: []const u8, parsed: cli.PushArgs, amb: cli.Ambient, pw_buf: *[max_password_len]u8, ) ResolveError!?ResolvedCredential { const user = parsed.user orelse return null; if (user.len == 0) return null; // an empty --user is an anonymous push std.debug.assert(!zit.sync.containsFossilWhitespace(user)); // pre-validated in runPush // A stored secret for this exact login is reused as-is: no re-derive, no // prompt, no re-persist. Only consulted when no explicit password is given. if (parsed.password == null and amb.push_password == null) { if (try storedSecretFor(gpa, io, dir, user)) |sec| { return .{ .credential = .{ .user = user, .secret_hex = sec }, .persist = false }; } } // Otherwise derive from a plaintext password. Precedence: --password, then // $ZIT_PASSWORD, then an interactive no-echo prompt. const password: []const u8 = parsed.password orelse amb.push_password orelse (readPasswordNoEcho(streams, user, pw_buf) orelse return error.NoPassword); const secret = zit.sync.deriveSharedSecret(project_code, user, password); return .{ .credential = .{ .user = user, .secret_hex = secret }, .persist = true }; } /// DECISION-3 — the 40-hex secret stored for `user` in this repo's config, or /// null when none is stored or it belongs to a different login. Read-only; the /// returned value is an owned copy (the config frame is freed here). fn storedSecretFor( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, user: []const u8, ) error{ OutOfMemory, Storage }!?[40]u8 { var file = dir.openFile(io, repo_filename, .{}) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; defer log.deinit(gpa); var oc = (log.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }) orelse return null; defer oc.deinit(gpa); if (oc.config.login_secret_hex.len != 40) return null; if (!std.mem.eql(u8, oc.config.login_user, user)) return null; var out: [40]u8 = undefined; @memcpy(&out, oc.config.login_secret_hex[0..40]); return out; } /// DECISION-3 — persist the derived login credential into the repo config, /// preserving every other field, then tighten the repository file to 0600 so /// the stored secret is owner-only at rest. The permission tightening is /// best-effort: the secret is already durable, so a filesystem that refuses /// chmod does not fail the push (perms + FDE are the boundary, not the sole /// guarantee — per DECISION-3). NEVER writes the plaintext password. fn storePushCredential( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, credential: zit.sync.Credential, ) PersistError!void { std.debug.assert(credential.secret_hex.len == 40); // The field-preserving RMW writes the credential without disturbing the // project code or a saved remote (a bare `putConfig` here would drop them). try updateConfig(gpa, io, dir, .{ .login = credential }); // Owner-only now that the file carries a credential (best-effort; see above). var file = dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch return; defer file.close(io); file.setPermissions(io, File.Permissions.fromMode(0o600)) catch {}; } /// CLI/IO seam — prompt on stderr and read one password line from the terminal /// with echo disabled, returning the trimmed line in `buf` (never its newline), /// or null on a read failure or an empty line. Echo is restored on every exit /// path; when stdin is not a terminal (`tcgetattr` fails) the line is read with /// no termios toggle. Not exercised by the automated gates (they pass /// `--password`/`$ZIT_PASSWORD`) — this is the interactive path. fn readPasswordNoEcho( streams: cli.Streams, user: []const u8, buf: *[max_password_len]u8, ) ?[]const u8 { streams.err.print("Password for {s}: ", .{user}) catch {}; streams.err.flush() catch {}; const fd = std.posix.STDIN_FILENO; const restore: ?std.posix.termios = blk: { var t = std.posix.tcgetattr(fd) catch break :blk null; const saved = t; t.lflag.ECHO = false; std.posix.tcsetattr(fd, .FLUSH, t) catch break :blk null; break :blk saved; }; defer if (restore) |saved| { std.posix.tcsetattr(fd, .FLUSH, saved) catch {}; streams.err.writeAll("\n") catch {}; streams.err.flush() catch {}; }; const n = std.posix.read(fd, buf) catch return null; if (n == 0) return null; var line = buf[0..n]; // Trim a single trailing CRLF/LF; interior bytes (including spaces) are kept. if (line.len > 0 and line[line.len - 1] == '\n') line = line[0 .. line.len - 1]; if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; if (line.len == 0) return null; return line; } /// Drives a push client (§3.2/§3.3) to convergence over `tr`, uploading the /// artifacts the peer lacks; returns the upload count and round count. The client /// announces `ms`'s holdings via igot and uploads what the peer gimme's (§3.3 /// r24: push sends content but never obtains it). Sans-IO on this side of /// `tr.exchange`. Its closed error set is the driver's own causes unioned with /// the transport's, so `runPush` switches it exhaustively. fn pushRun( gpa: std.mem.Allocator, tr: *transport.Transport, ms: *repostate.MemStore, project_code: []const u8, credential: ?zit.sync.Credential, ) sync_driver.RunError(*transport.Transport)!PushOutcome { var client = zit.sync.Client{ .state = zit.sync.initialState(.push), .operation = .push, .self_servercode = sync_servercode, .project_code = project_code, .version = .{ .version = sync_client_version }, // sync §6 — a named credential makes `buildRequest` emit the §6.1 login // card; null leaves the push anonymous (unchanged behavior). .credential = credential, }; defer client.deinit(gpa); const stats = try sync_driver.runSync(gpa, &client, ms.store(), tr, max_sync_rounds); // Read the upload counter before `deinit` runs (the return expression is // evaluated first): the exact number of `file` cards this session emitted. return .{ .rounds = stats.rounds, .sent = client.content_sent }; } // --------------------------------------------------------------------------- // `zit sync [URL]` — BIDIRECTIONAL convergence with a peer in ONE session. // Unlike pull-then-push, a single `operation=.sync` client announces igot, // requests gimme, AND uploads on one §3.7 exchange (§3.2 round structure: a sync // request carries both push and pull each round) — not a third copy of the driver // loop over two transport sessions. // Opens the cwd's `.zit`, resolves the URL (arg → saved remote), may // authenticate (sync uploads), persists what it receives like pull, and reports // both directions. A saved `zit remote` lets the URL be omitted. // --------------------------------------------------------------------------- /// What one sync run reports: uploads (`sent`) and the round count. Received is /// measured by the caller as the persisted-count growth (as pull does), so it is /// not carried here. const SyncOutcome = struct { rounds: usize, sent: usize }; fn runSync( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const parsed = switch (cli.parseSyncArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "sync", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "sync", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(sync_help) catch {}; return .ok; } // sync §6.3 — a login name is whitespace-free (mirrors push). Reject a // malformed --user as usage before the secret derivation's assertion. if (parsed.user) |u| { if (u.len > 0 and zit.sync.containsFossilWhitespace(u)) { streams.err.writeAll("zit sync: --user must not contain whitespace\n") catch {}; return .failure; } } // Locate the current repository before any network I/O. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "sync")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // sync §4/§6 — the explicit URL wins; an omitted one falls back to the saved // remote; a repo with neither is a usage error. `url_buf` outlives `open`. var url_buf: [logstore.max_config_str]u8 = undefined; const url = resolveRemoteUrl(gpa, io, root_dir, parsed.url, &url_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), error.Storage => return repoReadFail(streams, "sync"), error.NoRemote => return noRemoteError(streams, "sync"), }; var tr = transport.Transport.open(io, gpa, url) catch |e| return syncOpenFail(streams, "sync", url, e); defer tr.close(); // Snapshot holdings: the igot announcement and the baseline for the received // count. A sync both uploads what the peer lacks and receives what this side // lacks — the held set can only grow (§3.3 storage only adds content). var ms = repostate.MemStore.init(gpa); defer ms.deinit(); loadRepoHoldings(gpa, io, root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), error.Storage => return repoReadFail(streams, "sync"), }; const held_before = contentCount(ms.store()); // §9.1 — announce this repository's adopted project code (a real peer refuses // a mismatch). The buffer outlives the run — the client borrows it. var pc_buf: [zit.sync.max_projectcode_len]u8 = undefined; const project_code = repoProjectCode(gpa, io, root_dir, &pc_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), error.Storage => return repoReadFail(streams, "sync"), }; // sync §6 — resolve the login credential (null for anonymous). A sync // uploads, so it may authenticate exactly as push does; the plaintext lives // only in `pw_buf`/argv/env and is scrubbed on every exit from this scope. var pw_buf: [max_password_len]u8 = undefined; defer std.crypto.secureZero(u8, &pw_buf); const resolved = resolvePushCredential(gpa, io, root_dir, streams, project_code, .{ .url = url, .user = parsed.user, .password = parsed.password }, amb, &pw_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), error.Storage => return repoReadFail(streams, "sync"), error.NoPassword => { streams.err.writeAll("zit sync: no password supplied for --user\n") catch {}; return .failure; }, }; const outcome = syncRun(gpa, &tr, &ms, project_code, if (resolved) |r| r.credential else null) catch |e| return syncRunFail(streams, "sync", e); const written = persistStore(gpa, io, root_dir, &ms) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), error.Storage => return repoReadFail(streams, "sync"), error.Corrupt => return renderStorage(streams, root.abs, "the peer sent a malformed artifact name"), }; std.debug.assert(written >= held_before); const received = written - held_before; // DECISION-3 — persist a freshly derived secret only after the peer accepted // the authenticated session, and only when it was freshly derived (mirrors push). if (resolved) |r| { if (r.persist) { storePushCredential(gpa, io, root_dir, r.credential) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "sync"), else => return repoReadFail(streams, "sync"), }; } } if (outcome.sent == 0 and received == 0) { streams.out.writeAll("already in sync\n") catch {}; } else { streams.out.print("sent {d}, received {d} in {d} rounds\n", .{ outcome.sent, received, outcome.rounds }) catch {}; } return .ok; } /// Drives a bidirectional sync client (§3.2 round structure — both directions on /// ONE §3.7 session) to convergence over `tr`; returns the upload count and round /// count. /// The received count is the caller's persisted-count growth. Sans-IO on this /// side of `tr.exchange`; its closed error set is switched exhaustively by /// `runSync`. The sibling of `pullRun`/`pushRun`, differing only in the /// operation tag (`.sync`, which drives igot + gimme + upload on one session). fn syncRun( gpa: std.mem.Allocator, tr: *transport.Transport, ms: *repostate.MemStore, project_code: []const u8, credential: ?zit.sync.Credential, ) sync_driver.RunError(*transport.Transport)!SyncOutcome { var client = zit.sync.Client{ .state = zit.sync.initialState(.sync), .operation = .sync, .self_servercode = sync_servercode, .project_code = project_code, .version = .{ .version = sync_client_version }, // sync §6 — a named credential emits the §6.1 login card; null is anonymous. .credential = credential, }; defer client.deinit(gpa); const stats = try sync_driver.runSync(gpa, &client, ms.store(), tr, max_sync_rounds); return .{ .rounds = stats.rounds, .sent = client.content_sent }; } // --------------------------------------------------------------------------- // `zit remote [URL | --unset]` — read, set, or clear the single default peer URL // (sync §4/§6). Bare prints the saved remote; a URL saves it; --unset clears it. // A local, non-synced config value (`config.remote_url`) — anonymous only, no // credential in the URL — that lets pull/push/sync omit their URL argument. Not // a git-style named-remote registry: one value, matching the single-value config. // --------------------------------------------------------------------------- fn runRemote( 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.parseRemoteArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "remote", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "remote", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(remote_help) catch {}; return .ok; } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "remote")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); if (parsed.unset) { // Overlay remote_url with empty — clears the saved value, every other // config field preserved by the field-preserving RMW. updateConfig(gpa, io, root_dir, .{ .remote_url = "" }) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "remote"), else => return repoReadFail(streams, "remote"), }; streams.out.writeAll("remote cleared\n") catch {}; return .ok; } if (parsed.url.len > 0) { // A URL over the config-field cap cannot be stored (the putConfig assert); // reject it as usage rather than trip the assertion. if (parsed.url.len > logstore.max_config_str) { streams.err.print("zit remote: URL too long (max {d} bytes)\n", .{logstore.max_config_str}) catch {}; return .failure; } updateConfig(gpa, io, root_dir, .{ .remote_url = parsed.url }) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "remote"), else => return repoReadFail(streams, "remote"), }; streams.out.print("remote set to {s}\n", .{parsed.url}) catch {}; return .ok; } // Bare `zit remote` — print the saved remote (reusing resolveRemoteUrl's read // with an empty arg; NoRemote means none is saved). var url_buf: [logstore.max_config_str]u8 = undefined; const url = resolveRemoteUrl(gpa, io, root_dir, "", &url_buf) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "remote"), error.Storage => return repoReadFail(streams, "remote"), error.NoRemote => { streams.out.writeAll("no remote set\n") catch {}; return .ok; }, }; streams.out.print("{s}\n", .{url}) catch {}; return .ok; } /// `zit leaves [--all]` — list the open leaves of the check-in DAG (repo-state /// §7.1/§7.4/§7.5). Opens the repository through `Repository` and renders each /// leaf's full artifact ID and branch, mtime-descending (§7.5 SHOULD order), /// marking the leaf that matches the current checkout with "*". `--all` /// additionally lists closed leaves — §7.4: closed status is derived, so a /// closed leaf stays a leaf-table member. Enumeration is distinct from name /// resolution (§7.7): the listing is exactly the leaf table filtered by the /// §7.4 open predicate. An unborn repository has no leaves: empty stdout, exit 0. fn runLeaves( 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.parseLeavesArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "leaves", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "leaves", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(leaves_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "leaves", parsed.no_cache, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Resolve the current checkout's version to a rid so its leaf is marked // (§7.5). Absence/unknown/eventless leaves nothing marked — the mark is a // checkout convenience, never the source of the leaf set. const cur = repo.currentCheckoutRid(); return renderLeaves(arena, &repo, parsed.all, cur, streams); } /// One row of the leaf listing, gathered before the §7.5 mtime-descending sort. /// `hash` borrows the view's identity space (stable for the view's lifetime). const LeafRow = struct { rid: repostate.Rid, mtime: repostate.Mtime, hash: []const u8, open: bool, }; /// §7.5 order: modification time descending. Ties break by artifact ID ascending /// so the listing is a deterministic total order (IDs are unique per check-in). fn leafRowLess(_: void, a: LeafRow, b: LeafRow) bool { if (a.mtime != b.mtime) return a.mtime > b.mtime; return std.mem.order(u8, a.hash, b.hash) == .lt; } /// Gathers, sorts (§7.5), and prints the leaf listing to stdout. A row is /// included when `all` is set or the leaf is open (§7.4 open predicate). Each /// line is `<mark><id> <branch>`, `<mark>` = "* " for the current-checkout leaf /// else " "; under `all` a closed leaf appends " (closed)". Bounded by the /// leaf-table size. fn renderLeaves( arena: std.mem.Allocator, repo: *Repository, all: bool, cur: ?repostate.Rid, streams: cli.Streams, ) cli.Exit { var rows: std.ArrayList(LeafRow) = .empty; defer rows.deinit(arena); const bound = repo.state().leaves().rids.len; var scanned: usize = 0; var it = repo.state().leaves(); while (it.next()) |rid| { scanned += 1; std.debug.assert(scanned <= bound); const open = repo.state().isOpenLeaf(rid); if (!all and !open) continue; // A leaf-table rid is always a crosslinked check-in, so it has an event. const mtime = repo.state().mtimeOf(rid) orelse unreachable; rows.append(arena, .{ .rid = rid, .mtime = mtime, .hash = repo.hashOf(rid), .open = open, }) catch { streams.err.writeAll("zit leaves: out of memory\n") catch {}; return .failure; }; } std.debug.assert(rows.items.len <= bound); std.mem.sort(LeafRow, rows.items, {}, leafRowLess); for (rows.items) |row| { std.debug.assert(repo.state().isLeaf(row.rid)); std.debug.assert(all or row.open); const mark = if (cur != null and cur.? == row.rid) "* " else " "; const branch = repo.branchOf(row.rid); streams.out.print("{s}{s} {s}", .{ mark, row.hash, branch }) catch {}; if (all and !row.open) streams.out.writeAll(" (closed)") catch {}; streams.out.writeAll("\n") catch {}; } return .ok; } /// Displayed prefix of the full artifact hash on a timeline line. Display only: /// the total order keys on the FULL hash inside `checkinsByMtime`, so a display /// collision never perturbs ordering. const timeline_hash_display = 12; /// `zit timeline` — the global chronological history of every check-in /// (repo-state §2/§8.3): one line per check-in, event.mtime descending, ties by /// full artifact hash so the listing is byte-reproducible. Read-only: opens the /// repository, enumerates through the single `checkinsByMtime` query, renders, /// and mutates nothing. No positional, `-h`/`--help` only (the house /// `parseNoPositionalArgs` grammar). (contracts/timeline.md B-timeline-1..13.) fn runTimeline( 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.parseNoPositionalArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "timeline", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "timeline", .extra_argument, tok), }; // --help wins over every effect (B-timeline-8). if (parsed.help) { streams.out.writeAll(timeline_help) catch {}; return .ok; } // A cold `.full` verb: it reads adjacency (the §3.2 merge mark) and content // (the C-card comment), so it never takes the zero-parse image path. var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "timeline", false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); return renderTimeline(gpa, &repo, streams); } /// §3.2: a check-in that joins two or more lines of development — one carrying /// at least one merge parent (a second-or-later P-card hash), i.e. more than one /// distinct parent. The mainline (primary-only) check-in has exactly one. fn timelineIsMerge(state: *const repostate.RepoState, rid: repostate.Rid) bool { return state.parentCount(rid) >= 2; } /// The first line of a check-in comment (up to, excluding, the first LF), so a /// multi-line comment never injects a newline into the one-line-per-check-in /// listing (B-timeline-6). A comment with no LF is returned whole. fn firstCommentLine(comment: []const u8) []const u8 { const nl = std.mem.indexOfScalar(u8, comment, '\n') orelse return comment; return comment[0..nl]; } /// Renders the timeline to stdout: for each check-in in `checkinsByMtime` order, /// one line `<mark><hash12> <ISO> <branch> <first-comment-line>`. `<mark>` is /// "M " for a merge check-in (§3.2) else " ". Bounded by the enumeration size; /// each row reads and frees exactly one manifest (no unbounded retention). An /// empty repository prints nothing (B-timeline-7). fn renderTimeline(gpa: std.mem.Allocator, repo: *Repository, streams: cli.Streams) cli.Exit { const rids = repo.state().checkinsByMtime(gpa) catch return stateOom(streams, "timeline"); defer gpa.free(rids); var i: usize = 0; while (i < rids.len) : (i += 1) { const rid = rids[i]; // `checkinsByMtime` postcondition: every returned rid bears an event. const mtime = repo.state().mtimeOf(rid) orelse unreachable; // Mtime is the repository's millisecond timeline (repostate); the §4.6.4 // writer form is whole-second, so drop the sub-second remainder. var iso_buf: [19]u8 = undefined; const iso = cli.formatWriterTimestamp(@intCast(mtime / 1000), &iso_buf); const branch = repo.branchOf(rid); // §6.1, config main-branch fallback const mark = if (timelineIsMerge(repo.state(), rid)) "M " else " "; const hash = repo.hashOf(rid); const shown = hash[0..@min(hash.len, timeline_hash_display)]; // The comment borrows the manifest bytes, so print before freeing it. // A missing body or a bodyless (no C-card) manifest degenerates to an // empty comment — not a failure (errors defined out of existence here). var maybe = repo.manifest(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "timeline"), error.NotARepo, error.CannotRead, error.Corrupt => return repoReadFail(streams, "timeline"), }; 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 ""; // A `zit amend --comment` override (repo-state §5.2) supersedes the // manifest's own comment in the display; absent one, the manifest comment // stands. The override value borrows the view's tagxref bytes (valid for // the whole render), so it is safe to print after the manifest is freed. const comment = repo.override(rid, "comment", manifest_comment); streams.out.print("{s}{s} {s} {s} {s}\n", .{ mark, shown, iso, branch, comment }) catch {}; } return .ok; } /// `zit branch` — list the repository's branches (repo-state §6.3 derived /// grouping) one per line, name-ascending, marking the branch of the current /// checkout (§6.1/§6.4) with a leading "*". Pure presentation over two derived /// queries (`branches`, `branchOf`); no branch-creation surface (§6.2 is a /// later write-path slice). The default branch name comes from the view's /// config-derived `main_branch`, never a literal. fn runBranch( 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.parseBranchArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "branch", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "branch", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(branch_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "branch", parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // §6.3: the branch set is derived by grouping active branch tags — never // stored, never re-hardcoded here. const entries = repo.state().branches(gpa) catch return stateOom(streams, "branch"); defer gpa.free(entries); // §6.1/§6.4: mark the checked-out check-in's branch, resolved through the // active-tag filter and the config main-branch. `currentCheckoutRid` // guarantees `cur` bears an event, so `branchOf`'s precondition holds. const cur = repo.currentCheckoutRid(); const current_name: ?[]const u8 = if (cur) |r| repo.branchOf(r) else null; renderBranchList(streams.out, entries, current_name); return .ok; } /// Renders the §6.3 branch listing to stdout, one line per entry in `entries` /// order (name-ascending, as `branches` returns them). `current` is the branch /// name of the checked-out check-in (§6.1/§6.4) or null; the matching entry — /// at most one, since §6.3 grouping yields distinct names — gets a leading /// "* ", every other a two-space indent. A representative-closed branch (§6.3) /// appends " (closed)". Pure; bounded by `entries.len`. fn renderBranchList( out: *std.Io.Writer, entries: []const repostate.RepoState.BranchEntry, current: ?[]const u8, ) void { var marked: usize = 0; for (entries) |entry| { const is_current = current != null and std.mem.eql(u8, entry.name, current.?); if (is_current) marked += 1; std.debug.assert(marked <= 1); // distinct names ⇒ at most one mark out.writeAll(if (is_current) "* " else " ") catch {}; out.writeAll(entry.name) catch {}; if (entry.closed) out.writeAll(" (closed)") catch {}; out.writeAll("\n") catch {}; } std.debug.assert(marked <= 1); } /// The reserved 'sym-' prefix (repo-state §2): a symbolic name is stored with /// it and surfaced with it stripped. const sym_prefix = "sym-"; /// 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 (reachable only via a synced `T +sym-X *`, since zit mints no singleton /// syms itself). 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 tag list [CHECKIN]` (alias `ls`) — read-only tag enumeration over the /// crosslinked repo-view (repo-state §5.1/§5.2). With no CHECKIN, list the /// distinct active tag names in use, symbolic ('sym-') names surfaced with the /// prefix stripped (§2). With a CHECKIN, resolve it (§8.3) and list the active /// tags visible on it and their values. No write path — `tag add`/`cancel` is /// a later slice. Pure presentation over the `tagNames`/`tagsOf` derivations. fn runTag( 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.parseTagArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "tag", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "tag", .extra_argument, tok), .bad_subcommand => |tok| return cli.usageError(streams, "tag", .bad_subcommand, tok), }; if (parsed.help) { streams.out.writeAll(tag_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "tag", parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); if (parsed.checkin) |name| return runTagCheckin(gpa, &repo, name, streams); // §5.1/§2: the distinct active tag names in use — derived, never stored. const names = repo.state().tagNames(gpa) catch return stateOom(streams, "tag"); defer gpa.free(names); renderTagNames(streams.out, names); return .ok; } /// The `zit tag list CHECKIN` arm: resolve CHECKIN through the §8.3 procedure /// and list the active tags visible on it. An unresolvable or ambiguous name /// is a failure (exit 1, diagnostic to stderr); a resolved check-in with no /// active tags lists nothing (empty stdout, exit 0). fn runTagCheckin( gpa: std.mem.Allocator, repo: *Repository, name: []const u8, streams: cli.Streams, ) cli.Exit { const rid = repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit tag: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit tag: check-in '{s}' is ambiguous\n", .{name}) catch {}, } return .failure; }; const tags = repo.state().tagsOf(gpa, rid) catch return stateOom(streams, "tag"); defer gpa.free(tags); renderTagList(streams.out, tags); return .ok; } /// §5.1/§2 no-arg listing: one tag name per line, ascending. Only symbolic /// ('sym-') tags are user-facing names — surfaced with the prefix stripped. /// The machinery tags (`branch`, `closed`) and other reserved prefixes are not /// listed as bare names: branch identity is already present via its stripped /// 'sym-' tag. Pure; bounded by `names.len`. fn renderTagNames(out: *std.Io.Writer, names: []const []const u8) void { for (names) |name| { if (!std.mem.startsWith(u8, name, sym_prefix)) continue; out.writeAll(name[sym_prefix.len..]) catch {}; out.writeAll("\n") catch {}; } } /// §5.1/§5.2 CHECKIN listing: one active tag per line, ascending by raw name /// (as `tagsOf` returns them). A 'sym-' tag prints with the prefix stripped; /// every other tag (including the §6.4 `branch` tag and the `closed` marker) /// prints its raw name. A tag carrying a value payload (§5.2) prints /// `<name> <value>`; a valueless tag prints `<name>` alone. Pure; bounded by /// `tags.len`. fn renderTagList(out: *std.Io.Writer, tags: []const repostate.RepoState.TagEntry) void { for (tags) |tag| { const shown = if (std.mem.startsWith(u8, tag.name, sym_prefix)) tag.name[sym_prefix.len..] else tag.name; out.writeAll(shown) catch {}; if (tag.value) |v| { out.writeAll(" ") catch {}; out.writeAll(v) catch {}; } out.writeAll("\n") catch {}; } } /// `zit info NAME` — resolve NAME through the repo-state §8 procedure and /// describe the check-in it names: full artifact ID, branch (§6.1), leaf /// status (§7), and parents (§3.2/§3.3). A thin renderer over the shared /// resolver: it calls `resolve(name, .checkin)` exactly as `tag` does, never a /// second resolver. §8.5: resolution is recency-based (§8.2 max-mtime), so a /// name can name a closed or non-leaf check-in that `leaves` would not list — /// name resolution is not open-leaf enumeration. Read-only; joins the /// leaves/branch/tag family through `openRepo`. fn runInfo( 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.parseInfoArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "info", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "info", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(info_help) catch {}; return .ok; } // NAME is required; the arg is a symbolic name, not a check-in ID. const name = parsed.name orelse return cli.usageError(streams, "info", .no_name, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "info", parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // §8.3 full dispatch. §8.2's two MUST-NOTs: closed-ness and leaf-ness of a // candidate never filter the selection — that lives entirely in `resolve`. const rid = repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit info: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit info: name '{s}' is ambiguous\n", .{name}) catch {}, } return .failure; }; return renderInfo(arena, &repo, rid, streams); } /// Renders the resolved check-in's facts to stdout only: its full artifact ID, /// branch (§6.1, config-threaded main-branch as fallback), leaf status /// (§7.1 membership + §7.4 open/closed), the primary parent (§3.2), and each /// merge parent (§3.3). Values left-align at a fixed column. Data only — every /// diagnostic already went to stderr. Bounded by the check-in's parent count. fn renderInfo( arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, streams: cli.Streams, ) cli.Exit { // `resolve`'s postcondition: a resolved rid always bears an event, so every // derived query below has a defined answer — no failure path to invent. std.debug.assert(repo.state().mtimeOf(rid) != null); // Line 1: the canonical full artifact ID (echoes the resolved id even when // NAME was a prefix or a symbolic name). streams.out.print("{s}\n", .{repo.hashOf(rid)}) catch {}; // §6.1: the active-tag branch value, config main-branch as the fallback — // never the literal "trunk". streams.out.print("branch: {s}\n", .{repo.branchOf(rid)}) catch {}; // §7.1/§7.4: open (a leaf with no active closed tag), closed (a leaf that // carries one), or no (not a leaf at all). open/closed is defined only over // leaves, so a non-leaf's closed tag — if any — is not surfaced here. The // three arms are total over {isLeaf, hasActiveClosed}. const leaf_word: []const u8 = if (repo.state().isOpenLeaf(rid)) "open" else if (repo.state().isLeaf(rid)) "closed" else "no"; streams.out.print("leaf: {s}\n", .{leaf_word}) catch {}; // §3.2: the primary parent line (omitted for a root). const primary = repo.state().primaryParentOf(rid); if (primary) |p| streams.out.print("parent: {s}\n", .{repo.hashOf(p)}) catch {}; const mp = renderMergeParents(arena, repo, rid, primary, streams); if (mp != .ok) return mp; // Display overrides (repo-state §5.2, minted by `zit amend`): emit a // `comment:`/`user:`/`date:` line for each override ONLY when it is active, so // an un-amended check-in's info output stays byte-identical to its B-info // baseline (negative space). This is the one render path that surfaces a // check-in's overridden user and date. Values align at the same column as the // fixed lines above (8-space field, so `comment:`+1, `user:`/`date:`+4). const overrides = [_][]const u8{ "comment", "user", "date" }; for (overrides) |name| { std.debug.assert(name.len >= 1 and name.len <= 7); if (repo.state().activeTagValue(rid, name)) |v| { const pad = " "; // 8 spaces; the value column lands at index 9 streams.out.print("{s}:{s}{s}\n", .{ name, pad[0 .. 8 - name.len], v }) catch {}; } } return .ok; } /// Ascending raw-byte order over two artifact IDs, so the merge-parent lines /// print in a deterministic order (IDs are unique per check-in). fn lessByHash(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } /// Renders the §3.3 merge parents of `rid` — every parent but the primary — /// one `merge:` line each, sorted by artifact ID ascending. Gathered into an /// arena vector bounded by the parent count; OOM → exit 1. fn renderMergeParents( arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, primary: ?repostate.Rid, streams: cli.Streams, ) cli.Exit { var merges: std.ArrayList([]const u8) = .empty; defer merges.deinit(arena); const bound = repo.state().parentCount(rid); var scanned: usize = 0; var it = repo.state().parentsOf(rid); while (it.next()) |parent| { scanned += 1; std.debug.assert(scanned <= bound); if (primary != null and parent == primary.?) continue; merges.append(arena, repo.hashOf(parent)) catch { streams.err.writeAll("zit info: out of memory\n") catch {}; return .failure; }; } // Negative space: exactly the parents minus the single §3.2 primary — no // parent dropped or doubled. std.debug.assert(merges.items.len == bound - @as(usize, if (primary == null) 0 else 1)); std.mem.sort([]const u8, merges.items, {}, lessByHash); for (merges.items) |hash| streams.out.print("merge: {s}\n", .{hash}) catch {}; return .ok; } /// `zit ls [VERSION]` — list the effective file set of a resolved check-in, one /// `<blob-id> <filename>` line per file in canonical §4.5.3 order. Thin over the /// `Repository` facade: resolve VERSION (or fall back to the current checkout), /// then stream `repo.files`' iterator. All the §8.2 merge and §8.3 baseline /// policy lives in the library; this only selects the check-in and renders. fn runLs( 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.parseLsArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "ls", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "ls", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(ls_help) catch {}; return .ok; } var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "ls", parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // An explicit VERSION resolves through the §8.3 name procedure; an omitted // VERSION lists the current checkout (§7.5). An unborn or absent checkout has // no check-in to list — a failure, not empty output. const rid = if (parsed.version) |name| repo.resolve(name, .checkin) catch |e| return lsResolveFail(streams, name, e) else repo.currentCheckoutRid() orelse return lsNoCheckout(streams); var list = (repo.files(gpa, rid) catch |e| return lsFilesFail(streams, e)) orelse return lsMissingContent(streams); defer list.deinit(gpa); // Data only: one line per effective file. Iteration is over in-memory cards // (no I/O), so nothing but the write itself can fail mid-stream. var it = list.iterator(); while (it.next()) |f| { std.debug.assert(f.hash != null); // the iterator suppresses delete markers streams.out.print("{s} {s}\n", .{ f.hash.?, f.filename }) catch {}; } return .ok; } /// Maps a `Repository.resolve` failure for `ls` to its diagnostic + exit 1. fn lsResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit { switch (e) { error.NotFound => s.err.print("zit ls: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => s.err.print("zit ls: name '{s}' is ambiguous\n", .{name}) catch {}, } return .failure; } /// Maps a `Repository.files` failure to its diagnostic + exit 1. Closed over /// `FilesError` so the switch stays exhaustive; the open/content phrasings match /// the shared `repoOpenFail` text. fn lsFilesFail(s: cli.Streams, e: Repository.FilesError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit ls: out of memory\n") catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.writeAll("zit ls: cannot read repository: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit ls: cannot read repository: repository is corrupt\n") catch {}, error.NotManifest => s.err.writeAll("zit ls: not a check-in\n") catch {}, error.BaselineUnresolvable => s.err.writeAll("zit ls: cannot resolve the baseline of a delta check-in\n") catch {}, error.BaselineIsDelta => s.err.writeAll("zit ls: the baseline of a delta check-in is itself a delta\n") catch {}, } return .failure; } fn lsNoCheckout(s: cli.Streams) cli.Exit { s.err.writeAll("zit ls: no check-in to list (the working checkout is unborn); name a version\n") catch {}; return .failure; } fn lsMissingContent(s: cli.Streams) cli.Exit { s.err.writeAll("zit ls: check-in content is missing\n") catch {}; return .failure; } /// `zit cat FILE [VERSION]` — emit one file's uncompressed content at a resolved /// check-in. `ls` narrowed to a single file (§8.4 single-name lookup): resolve /// the version the same way `ls` does, look the file up in the effective set, /// reconstruct its blob (delta chain → uncompressed, hash-verified §7.2), and /// write the bytes verbatim — no framing, no trailing newline (content is /// `[]const u8`, so embedded NUL and arbitrary binary bytes pass through). fn runCat( 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.parseCatArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "cat", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "cat", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(cat_help) catch {}; return .ok; } const file = parsed.file orelse return cli.usageError(streams, "cat", .no_file, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "cat", parsed.no_cache, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Version resolution mirrors `ls`: an explicit VERSION through the §8.3 name // procedure, an omitted one the current checkout (§7.5); an unborn/absent // checkout has no check-in to read. const rid = if (parsed.version) |name| repo.resolve(name, .checkin) catch |e| return catResolveFail(streams, name, e) else repo.currentCheckoutRid() orelse return catNoCheckout(streams); // §8.4 single-name lookup: null ⇒ the file is not in this check-in's // effective set (absent, or a delete marker suppresses it). const found = (repo.fileAt(gpa, rid, file) catch |e| return catFail(streams, e)) orelse return catNotFound(streams, file); defer Repository.freeFileCard(gpa, found); std.debug.assert(found.hash != null); // fileAt yields only hash-bearing cards (§4.7.6) // Reconstruct the blob and verify it hashes to its F-card id (§7.2). A null // blob is a stored-content gap for a named-but-absent file. const bytes = (repo.blob(gpa, found.hash.?) catch |e| return catFail(streams, e)) orelse return catMissingContent(streams, file); defer gpa.free(bytes); streams.out.writeAll(bytes) catch {}; return .ok; } /// Maps a `Repository.resolve` failure for `cat` to its diagnostic + exit 1. fn catResolveFail(s: cli.Streams, name: []const u8, e: Repository.ResolveError) cli.Exit { switch (e) { error.NotFound => s.err.print("zit cat: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => s.err.print("zit cat: name '{s}' is ambiguous\n", .{name}) catch {}, } return .failure; } /// Maps a `Repository.fileAt` / `blob` failure to its diagnostic + exit 1. /// Switches the full `FilesError` set exhaustively; `blob`'s narrower `OpenError` /// coerces into it, so one renderer covers both surfaces. The open/content /// phrasings match the shared `repoOpenFail` text. fn catFail(s: cli.Streams, e: Repository.FilesError) cli.Exit { switch (e) { error.OutOfMemory => s.err.writeAll("zit cat: out of memory\n") catch {}, error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {}, error.CannotRead => s.err.writeAll("zit cat: cannot read repository: input/output error\n") catch {}, error.Corrupt => s.err.writeAll("zit cat: cannot read repository: repository is corrupt\n") catch {}, error.NotManifest => s.err.writeAll("zit cat: not a check-in\n") catch {}, error.BaselineUnresolvable => s.err.writeAll("zit cat: cannot resolve the baseline of a delta check-in\n") catch {}, error.BaselineIsDelta => s.err.writeAll("zit cat: the baseline of a delta check-in is itself a delta\n") catch {}, } return .failure; } fn catNoCheckout(s: cli.Streams) cli.Exit { s.err.writeAll("zit cat: no check-in to read (the working checkout is unborn); name a version\n") catch {}; return .failure; } fn catNotFound(s: cli.Streams, file: []const u8) cli.Exit { s.err.print("zit cat: file '{s}' is not in the check-in\n", .{file}) catch {}; return .failure; } fn catMissingContent(s: cli.Streams, file: []const u8) cli.Exit { s.err.print("zit cat: content for '{s}' is missing\n", .{file}) catch {}; return .failure; } /// `zit artifact HASH [--raw]` — zit's plumbing window (contracts/artifact.md). /// A strictly read-only describe/dump of ONE stored artifact addressed by its /// hash. HASH covers every artifact kind (file blobs included), so it resolves /// through the artifact store's prefix lookup, not the symbolic-name procedure /// `info`/`ls`/`cat` use. No flag → the whatis summary; `--raw` → the exact /// stored bytes. All card interpretation flows through the one `artifact` /// parser — never a CLI-side re-parse. fn runArtifact( 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.parseArtifactArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "artifact", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "artifact", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(artifact_help) catch {}; return .ok; } const hash_arg = parsed.hash orelse return cli.usageError(streams, "artifact", .no_hash, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = openRepo(gpa, io, arena, streams, "artifact", false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); // Canonicalize HASH: lowercase, hex-only, length band 4..64 (§3.2/§3.6). A // syntactically invalid HASH is the SAME not-found family as an unknown one // (B-artifact-8) — no distinct error surface. var canon_buf: [2 * logstore.max_hash_len]u8 = undefined; const canon = canonicalizeHexPrefix(hash_arg, &canon_buf) orelse return artifactNotFound(streams, hash_arg); var full_buf: [2 * logstore.max_hash_len]u8 = undefined; const full = (repo.resolveArtifactPrefix(gpa, canon, &full_buf) catch |e| switch (e) { error.Ambiguous => return artifactAmbiguous(streams, hash_arg), // B-artifact-7 error.OutOfMemory => return artifactOom(streams), error.NotARepo, error.CannotRead, error.Corrupt => |oe| return repoOpenFail(streams, "artifact", oe), }) orelse return artifactNotFound(streams, hash_arg); // B-artifact-8 // §7.2-verified stored bytes. `resolveArtifactPrefix` named a real stored // artifact, so a null here would be a store race, not user input. const bytes = (repo.blob(gpa, full) catch |e| return repoOpenFail(streams, "artifact", e)) orelse return artifactNotFound(streams, hash_arg); defer gpa.free(bytes); // B-artifact-4 round-trip invariant: the emitted bytes hash back to HASH. std.debug.assert(artifact.verify(full, bytes)); if (parsed.raw) { // B-artifact-4: verbatim, no trailing newline, binary-safe (mirrors cat). // Never parses or verifies Z. streams.out.writeAll(bytes) catch {}; return .ok; } return renderArtifactSummary(streams, gpa, full, bytes); } /// Canonicalize a HASH argument into `out`: lowercase every byte, accept only /// hex digits and only a 4..64-length token (§3.2 length band, §3.6 case- /// insensitive prefix). Returns null for anything outside that — the caller /// folds null into the not-found refusal, so a bad HASH never has its own /// error surface (B-artifact-8). No allocation. fn canonicalizeHexPrefix(arg: []const u8, out: *[2 * logstore.max_hash_len]u8) ?[]const u8 { if (arg.len < 4 or arg.len > out.len) return null; for (arg, 0..) |c, i| { const lc = std.ascii.toLower(c); if (!((lc >= '0' and lc <= '9') or (lc >= 'a' and lc <= 'f'))) return null; out[i] = lc; } return out[0..arg.len]; } /// Render the whatis summary of a resolved artifact to stdout (data-only). Tries /// the Z-tolerant `parseWindow`: on success the artifact is structural (its type, /// size, one line per card, and the §6.2 Z verdict); on any framing/grammar/ /// classification fault it is an opaque blob (B-artifact-3) — type file, size, /// and the NUL binary verdict, never an error. Exit 0 either way. fn renderArtifactSummary(streams: cli.Streams, gpa: std.mem.Allocator, full: []const u8, bytes: []const u8) cli.Exit { streams.out.print("{s}\n", .{full}) catch {}; var m = artifact.parseWindow(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return artifactOom(streams), else => { // A blob is a first-class answer (B-artifact-3), not a failure. streams.out.writeAll("type: file\n") catch {}; streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {}; streams.out.print("binary: {s}\n", .{if (merge3.isBinary(bytes)) "yes" else "no"}) catch {}; return .ok; }, }; defer m.deinit(); streams.out.print("type: {s}\n", .{m.kind.diagnosticName()}) catch {}; streams.out.print("size: {d} bytes\n", .{bytes.len}) catch {}; renderCardLines(streams, m); // B-artifact-5: the Z verdict is REPORTED, not enforced — a mismatch stays // exit 0 (this is a window, not a gate). Emitted only when a Z card is // present; `parseWindow` succeeded so the framed content is recoverable. if (m.z != null) { const content = artifact.frameNoZ(bytes).?; // non-null: parseWindow framed it const verdict = artifact.verifyZCard(content); std.debug.assert(verdict != .absent); // a present Z card is structurally last streams.out.print("Z: {s}\n", .{if (verdict == .match) "verified" else "checksum mismatch"}) catch {}; } return .ok; } /// One line per card of a structural artifact, in ascending card-letter order /// (A..W; Z is the separate verdict line the caller prints). §4.4 makes wire /// order non-decreasing, so lane order == stored order — this reads decoded /// struct fields, never re-walking the bytes. Repeatable lanes (F/J/M/P/Q/T) /// render in retained wire order, one line per card. Best-effort writes. fn renderCardLines(s: cli.Streams, m: artifact.Manifest) void { if (m.a) |a| { s.out.print("A {s} {s}", .{ a.name, a.target }) catch {}; if (a.source) |src| s.out.print(" {s}", .{src}) catch {}; s.out.writeAll("\n") catch {}; } if (m.b) |b| s.out.print("B {s}\n", .{b.hash}) catch {}; if (m.c) |c| s.out.print("C {s}\n", .{c.comment}) catch {}; if (m.d) |d| s.out.print("D {s}\n", .{d.timestamp}) catch {}; if (m.e) |e| s.out.print("E {s} {s}\n", .{ e.timestamp, e.technote_id }) catch {}; for (m.f) |f| { s.out.print("F {s}", .{f.filename}) catch {}; if (f.hash) |h| s.out.print(" {s}", .{h}) catch {}; if (f.perm) |p| s.out.print(" {s}", .{p}) catch {}; if (f.old_name) |o| s.out.print(" {s}", .{o}) catch {}; s.out.writeAll("\n") catch {}; } if (m.g) |g| s.out.print("G {s}\n", .{g.hash}) catch {}; if (m.h) |h| s.out.print("H {s}\n", .{h.title}) catch {}; if (m.i) |i| s.out.print("I {s}\n", .{i.hash}) catch {}; for (m.j) |j| s.out.print("J {s} {s}\n", .{ j.field, j.value }) catch {}; if (m.k) |k| s.out.print("K {s}\n", .{k.ticket_id}) catch {}; if (m.l) |l| s.out.print("L {s}\n", .{l.title}) catch {}; for (m.m) |mc| s.out.print("M {s}\n", .{mc.hash}) catch {}; if (m.n) |n| s.out.print("N {s}\n", .{n.mimetype}) catch {}; for (m.p) |p| { s.out.writeAll("P") catch {}; var it = p.hashes(); while (it.next()) |hh| s.out.print(" {s}", .{hh}) catch {}; s.out.writeAll("\n") catch {}; } for (m.q) |q| { const sign_byte: u8 = if (q.sign == .cherrypick) '+' else '-'; s.out.print("Q {c}{s}", .{ sign_byte, q.target }) catch {}; if (q.baseline) |base| s.out.print(" {s}", .{base}) catch {}; s.out.writeAll("\n") catch {}; } if (m.r) |r| s.out.print("R {s}\n", .{r.checksum}) catch {}; for (m.t) |t| { const tgt = t.target orelse "*"; // §4.7.16 self-referential tag s.out.print("T {c}{s} {s}", .{ t.sign.byte(), t.name, tgt }) catch {}; if (t.value) |v| s.out.print(" {s}", .{v}) catch {}; s.out.writeAll("\n") catch {}; } if (m.u) |u| s.out.print("U {s}\n", .{u.login}) catch {}; if (m.w) |w| s.out.print("W {d} bytes\n", .{w.len}) catch {}; } fn artifactNotFound(s: cli.Streams, arg: []const u8) cli.Exit { s.err.print("zit artifact: no artifact matches '{s}'\n", .{arg}) catch {}; return .failure; } fn artifactAmbiguous(s: cli.Streams, arg: []const u8) cli.Exit { s.err.print("zit artifact: hash '{s}' is ambiguous\n", .{arg}) catch {}; return .failure; } fn artifactOom(s: cli.Streams) cli.Exit { s.err.writeAll("zit artifact: out of memory\n") catch {}; return .failure; } /// `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. 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 target_id = parsed.checkin orelse return cli.usageError(streams, "update", .no_checkin, ""); if (!artifact.validateName(target_id)) { streams.err.print( "zit update: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n", .{target_id}, ) catch {}; return .failure; } 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); update.applyCheckin(gpa, io, root_dir, &log, target_id) catch |err| return updateApplyFail(streams, target_id, err); return .ok; } 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; } fn updateApplyFail(s: cli.Streams, target_id: []const u8, err: update.ApplyError) cli.Exit { switch (err) { error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {}, error.InvalidCheckinId => s.err.print( "zit update: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n", .{target_id}, ) catch {}, error.CheckinNotFound => s.err.print("zit update: check-in '{s}' not found\n", .{target_id}) catch {}, error.CheckinContentMissing => s.err.print( "zit update: cannot read repository: check-in '{s}' content is missing\n", .{target_id}, ) catch {}, error.CheckinNameMismatch => s.err.print( "zit update: cannot read repository: check-in '{s}' content does not match its name\n", .{target_id}, ) catch {}, error.TargetNotManifest => s.err.print("zit update: check-in '{s}' is not a valid manifest\n", .{target_id}) catch {}, error.BaselineNotFound => s.err.print( "zit update: cannot read repository: check-in '{s}' baseline is not found\n", .{target_id}, ) catch {}, error.BaselineContentMissing => s.err.print( "zit update: cannot read repository: check-in '{s}' baseline content is missing\n", .{target_id}, ) catch {}, error.BaselineNameMismatch => s.err.print( "zit update: cannot read repository: check-in '{s}' baseline content does not match its name\n", .{target_id}, ) catch {}, error.BaselineNotManifest => s.err.print( "zit update: check-in '{s}' baseline is not a valid manifest\n", .{target_id}, ) catch {}, error.BaselineIsDelta => s.err.print( "zit update: check-in '{s}' baseline is not fully resolvable\n", .{target_id}, ) catch {}, error.StateRecordInvalid, error.Corrupt => s.err.writeAll("zit update: cannot read repository: repository is corrupt\n") catch {}, error.InvalidPath => s.err.print("zit update: check-in '{s}' names an invalid file path\n", .{target_id}) catch {}, error.ReservedPath => s.err.print("zit update: check-in '{s}' names a reserved file path\n", .{target_id}) catch {}, error.SymlinkUnsupported => s.err.print( "zit update: check-in '{s}' contains an unsupported symlink entry\n", .{target_id}, ) catch {}, error.CurrentStateInvalid => s.err.writeAll("zit update: current working-tree state is invalid\n") catch {}, error.WorktreeNotClean => s.err.writeAll("zit update: working tree is not clean\n") catch {}, error.UnmanagedCollision => s.err.writeAll("zit update: an untracked file or directory is in the way\n") catch {}, error.Storage => s.err.writeAll("zit update: cannot update working tree: input/output error\n") catch {}, error.TransactionUnavailable => s.err.writeAll("zit update: cannot create a transaction directory\n") catch {}, error.RollbackFailed => s.err.writeAll( "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n", ) catch {}, error.CleanupFailed => s.err.writeAll( "zit update: update committed, but the transaction directory could not be removed\n", ) 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 path/baseline arms are /// unreachable for a check-in this view already resolved, so they fold into the /// generic corrupt-repository message. fn bisectMoveFail(s: cli.Streams, err: update.ApplyError) cli.Exit { switch (err) { error.OutOfMemory => return bisectOom(s), 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"), else => 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. // =========================================================================== /// The narrow read-failure set the annotate content path collapses the /// `Repository` file/blob errors into, so the renderer switches once. const AnnotateReadError = error{ OutOfMemory, Storage, Corrupt }; /// PATH's effective content in the check-in `rid` (artifact §8 via /// `Repository.fileAt` + `blob`), duplicated into `arena`, or null when PATH is /// absent there (never added, or a delta delete). Transient `gpa` allocations /// (the F card, the verified blob) are freed before returning; only the arena /// copy survives, owned by the caller's arena. fn annotateContentAt( gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid, path: []const u8, ) AnnotateReadError!?[]const u8 { const maybe_card = repo.fileAt(gpa, rid, path) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, // NotARepo/Corrupt/NotManifest/BaselineUnresolvable/BaselineIsDelta all // mean this reachable check-in cannot be reconstructed — corrupt. else => error.Corrupt, }; const card = maybe_card orelse return null; defer Repository.freeFileCard(gpa, card); // The effective-set iterator suppresses delete markers, so a returned card // always carries a hash (§4.7.6). const bytes = (repo.blob(gpa, card.hash.?) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.CannotRead => error.Storage, error.NotARepo, error.Corrupt => error.Corrupt, }) orelse return null; defer gpa.free(bytes); return try arena.dupe(u8, bytes); } /// One check-in in PATH's ancestry, gathered before the mtime-ascending sort that /// fixes the deterministic processing order (B-annotate-5). `hash` borrows the /// view's identity space (stable for the view's lifetime). const AnnotateEntry = struct { rid: repostate.Rid, mtime: repostate.Mtime, hash: []const u8, }; /// Processing order = event mtime ascending, ties by lexicographic artifact hash /// (the per-file history order, reversed so a parent precedes its child). Total /// and deterministic — never map/hash iteration order. fn annotateEntryLess(_: void, a: AnnotateEntry, b: AnnotateEntry) bool { if (a.mtime != b.mtime) return a.mtime < b.mtime; return std.mem.order(u8, a.hash, b.hash) == .lt; } fn annotateReadFail(streams: cli.Streams, e: AnnotateReadError) cli.Exit { switch (e) { error.OutOfMemory => streams.err.writeAll("zit annotate: out of memory\n") catch {}, error.Storage => streams.err.writeAll("zit annotate: cannot read repository: input/output error\n") catch {}, error.Corrupt => streams.err.writeAll("zit annotate: cannot read repository: repository is corrupt\n") catch {}, } return .failure; } /// The B-annotate-10 refusal: PATH is not in the current check-out's managed set. /// Distinguishes an untracked-but-present working file from a nonexistent one so /// the diagnostic tells the user which mistake they made. Exit 1, stdout empty. fn annotateNotTracked(io: std.Io, root_dir: std.Io.Dir, path: []const u8, streams: cli.Streams) cli.Exit { const present = blk: { var f = root_dir.openFile(io, path, .{}) catch break :blk false; f.close(io); break :blk true; }; if (present) streams.err.print("zit annotate: '{s}' is not tracked at the current check-out\n", .{path}) catch {} else streams.err.print("zit annotate: '{s}': no such tracked file\n", .{path}) catch {}; return .failure; } /// `zit annotate PATH` — parse (one repo-relative positional via the shared /// one-positional grammar), open the repository read-only, and render the /// annotation. `-h`/`--help` wins; a missing PATH / unknown option / second /// positional is a usage error (exit 2). (B-annotate-12/-13/-16.) fn runAnnotate( 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, "annotate", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "annotate", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(annotate_help) catch {}; return .ok; } const path = parsed.checkin orelse return cli.usageError(streams, "annotate", .no_file, ""); var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; // A cold read: it walks ancestry (§3.3) and reconstructs effective sets, so // it takes the full derive, never the zero-parse image path. const rc = openRepo(gpa, io, arena, streams, "annotate", false, false, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); return annotateFile(gpa, io, &repo, root_dir, path, streams); } /// Gather PATH's version chain over the current check-out's full ancestry, /// attribute each target line through the pure engine, and render. A scratch /// arena owns every derived array and every materialized version body; the /// repository content channel stays on `gpa` so it is freed by `repo.deinit`. fn annotateFile( gpa: std.mem.Allocator, io: std.Io, repo: *Repository, root_dir: std.Io.Dir, path: []const u8, streams: cli.Streams, ) cli.Exit { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // The annotated version is PATH at the current check-out. An unborn check-out // tracks nothing (B-annotate-10 / -14 boundary). const cur = repo.currentCheckoutRid() orelse return annotateNotTracked(io, root_dir, path, streams); // The full ancestry of `cur` (repo-state §3.3, merge parents included per // §3.2) comes from the ONE general-purpose ancestry query — never a CLI-side // walk (the merge-base/bisect posture). The set is order-independent here; // the mtime sort below fixes the deterministic processing order. const ancestor_rids = repo.ancestorsOf(gpa, cur) catch return stateOom(streams, "annotate"); defer gpa.free(ancestor_rids); var entries: std.ArrayList(AnnotateEntry) = .empty; for (ancestor_rids) |rid| { entries.append(arena, .{ .rid = rid, .mtime = repo.state().mtimeOf(rid) orelse 0, .hash = repo.hashOf(rid), }) catch return stateOom(streams, "annotate"); } std.mem.sort(AnnotateEntry, entries.items, {}, annotateEntryLess); // rid → node index in processing order, so parent links resolve to indices. var index_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty; for (entries.items, 0..) |e, i| index_of.put(arena, e.rid, @intCast(i)) catch return stateOom(streams, "annotate"); // Build the pure engine's node view: content + §3.2 primary/merge parents. const nodes = arena.alloc(annotate_mod.Node, entries.items.len) catch return stateOom(streams, "annotate"); for (entries.items, 0..) |e, i| { const content = annotateContentAt(gpa, arena, repo, e.rid, path) catch |err| return annotateReadFail(streams, err); const primary_rid = repo.state().primaryParentOf(e.rid); const primary: ?u32 = if (primary_rid) |pr| index_of.get(pr) else null; var merges: std.ArrayList(u32) = .empty; var it = repo.state().parentsOf(e.rid); while (it.next()) |p| { if (primary_rid != null and p == primary_rid.?) continue; // primary handled separately if (index_of.get(p)) |idx| merges.append(arena, idx) catch return stateOom(streams, "annotate"); } nodes[i] = .{ .content = content, .primary = primary, .merges = merges.items }; } const cur_index = index_of.get(cur).?; // cur is self-inclusive in its ancestry const target = nodes[cur_index].content orelse return annotateNotTracked(io, root_dir, path, streams); // Provenance is undefined over binary content (B-annotate-9, the merge3 NUL // predicate) — refuse before emitting anything. if (merge3.isBinary(target)) { streams.err.print("zit annotate: '{s}': cannot annotate binary content\n", .{path}) catch {}; return .failure; } const origins = annotate_mod.attribute(arena, nodes, cur_index) catch return stateOom(streams, "annotate"); // Render one output line per file line: the origin's 10-char artifact-hash // prefix, a space, the ISO date (UTC, from the origin's event mtime), a space, // then the file line verbatim (its own trailing newline carries the break). const lines = merge3.splitLines(arena, target) catch return stateOom(streams, "annotate"); std.debug.assert(lines.len == origins.len); for (lines, origins) |line, origin| { const oe = entries.items[origin]; const full = repo.hashOf(oe.rid); const prefix = full[0..@min(full.len, 10)]; var iso_buf: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(@intCast(oe.mtime / 1000), &iso_buf); const iso_date = ts[0..10]; // YYYY-MM-DD streams.out.print("{s} {s} {s}", .{ prefix, iso_date, merge3.lineBytes(target, line) }) catch {}; } return .ok; } 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", 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; }, }; 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 { // 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) 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) 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) 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). // ========================================================================== 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 via the shared one-positional template (B-merge-25/26): NAME plus // `-h`/`--help`. Help wins; an unknown option or a second positional is a // usage error (exit 2). A missing NAME is a usage error — NAME is // mandatory (unlike `update`, which defaults to tip). const parsed = switch (cli.parseUpdateArgs(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), }; 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", 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 {}, } 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) { 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), }; // 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, streams); } /// 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, 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. var base_list = (repo.files(gpa, base) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams); defer base_list.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 = (repo.files(gpa, other) catch |e| return mergeFilesFail(streams, e)) orelse return mergeCorrupt(streams); defer oth_list.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); var bit = base_list.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; } var oit = oth_list.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); }, } } // Materialize (journal-armed) and record the pending merge parent = other's // hash. `applyMerge` writes the checkout FIRST, so a conflict merge still // records the parent for `zit commit`/`zit undo` (B-merge-3/7). update.applyMerge(gpa, io, root_dir, entries.items, .{ .version = ck.version, .branch = ck.branch, .merge_parent = repo.hashOf(other), }) 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( "merged {s} into the working tree ({d} file(s) changed)\n", .{ repo.hashOf(other), 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.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, 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. 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); } } // 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. 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. 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; } // ========================================================================== // `zit commit` — mints a check-in (contracts/commit.md B-commit-1..20). The // first verb that appends to the log AND advances the working state. A thin // consumer: it decides which files at which content, hands the library RFC card // structs, and the library owns all format work (`artifact.create` computes the // Z self-check, orders/escapes cards, frames the artifact). No logic enters any // library module; no `std.http`, no network; history is append-only. The one // deep, RFC-normative unit — the F-set composition — is `cli.composeFCards`, // pure and separately unit-tested; `runCommit` is thin wiring around it. // ========================================================================== /// SHA3-256 digest length: the on-disk log frame is keyed by these 32 raw bytes /// (logstore.max_hash_len), while the artifact NAME is their 64-hex form. `init` /// wrote the SHA3-256 policy (B-commit-16), so the algorithm is this constant. const digest_len = 32; /// Decodes a 64-hex artifact name into its 32-byte binary digest — the log-frame /// key. Returns null for a non-64-hex string (a corrupt checkout `version`), so /// the caller maps it to the parent-corrupt diagnostic rather than trapping. fn hexToDigest(hex: []const u8) ?[digest_len]u8 { if (hex.len != digest_len * 2) return null; var d: [digest_len]u8 = undefined; _ = std.fmt.hexToBytes(&d, hex) catch return null; return d; } /// Lowercase-hex-encodes a raw binary `digest` into `out`, returning the used /// prefix — the log-frame key's wire NAME (sync §3.3). The inverse of /// `decodeWireName`; both live at the log↔store boundary so no other module /// sees a raw digest cross into wire space. fn digestToWireName(digest: []const u8, out: *[2 * digest_len]u8) []const u8 { std.debug.assert(digest.len <= digest_len); const hexdigits = "0123456789abcdef"; for (digest, 0..) |b, i| { out[2 * i] = hexdigits[b >> 4]; out[2 * i + 1] = hexdigits[b & 0x0f]; } return out[0 .. 2 * digest.len]; } /// Decodes a wire artifact NAME (40- or 64-hex, sync §3.3) into its raw binary /// digest — the log-frame key. Returns null for anything that is not an /// even-length hex string of at most `digest_len` bytes, so a malformed peer /// name is rejected cleanly rather than trapping. The inverse of /// `digestToWireName`. fn decodeWireName(name: []const u8, out: *[digest_len]u8) ?[]const u8 { if (name.len == 0 or name.len % 2 != 0) return null; const nbytes = name.len / 2; if (nbytes > digest_len) return null; _ = std.fmt.hexToBytes(out[0..nbytes], name) catch return null; return out[0..nbytes]; } /// Reads the whole-second wall clock for the D card (B-commit-7), preferring the /// `SOURCE_DATE_EPOCH` reproducible-build convention when set (a well-known, /// tell-free way to pin the clock across a process boundary — how the e2e suite /// fixes the timestamp of the spawned binary). Falls back to the real /// (wall-clock) `Io.Clock`. The single ambient-time read in the CLI. fn commitEpoch(io: std.Io, env: *const std.process.Environ.Map) i64 { if (env.get("SOURCE_DATE_EPOCH")) |raw| { const trimmed = std.mem.trim(u8, raw, " \t\r\n"); if (std.fmt.parseInt(i64, trimmed, 10)) |v| return v else |_| {} } return Io.Timestamp.now(io, .real).toSeconds(); } /// 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) not_regular, // present but not a regular file (B-commit-10) io_error, // a read/store device failure (B-commit-15) 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; if (!trackedFileSizeAllowed(st.size)) return .io_error; 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, } }; } /// 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; } /// `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. 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; // 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): read + parse it for carry-forward, the // no-op verdict (B-commit-9/11), AND the per-path delta bases. Loaded // BEFORE capture — it has no dependency on capture — so each captured // blob can be delta-encoded against the same path's parent blob (§7.2 // storage compaction). Slice-1 parents are always full manifests, so no // delta resolution is reached here. 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) { const pdigest = hexToDigest(ck.version) orelse return commitParentCorrupt(streams); const prid = log.lookup(&pdigest) orelse return commitParentCorrupt(streams); parent_manifest_rid = prid; parent_bytes = (log.content(gpa, prid) catch |e| return commitContentFail(streams, e)) orelse return commitParentCorrupt(streams); const parent_im = artifact.parseForImport(gpa, parent_bytes.?) catch return commitParentCorrupt(streams); pm = parent_im.manifest; pm_normalized = parent_im.normalized; parent_f = 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; const bd = hexToDigest(h) 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 => return commitNotRegular(streams, path), .io_error => return commitCaptureIo(streams, path), .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 => return commitNotRegular(streams, key), .io_error => return commitCaptureIo(streams, key), .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; } } } // 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); if (composed.unchanged) { 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`, so a non-merge commit reads nothing extra and its // bytes/I/O are unperturbed. Placed before any durable log write, so a // refusal commits nothing. if (is_merge) { 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; } } // 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, }}; 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 = null, .u = .{ .login = author }, .z = null, .w = null, .f = composed.f, .j = &.{}, .m = &.{}, .p = if (is_root) &.{} else &child_parents, .q = &.{}, .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 (if any) is consumed by this check-in, so the advanced // checkout clears it (`merge_parent = ""`, B-merge-9 one-shot clear). checkout.write(io, root_dir, .{ .version = manifest_name, .branch = target_branch, .merge_parent = "" }) 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 {}; } 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; } /// 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. fn commitParentCorrupt(s: cli.Streams) cli.Exit { s.err.writeAll("zit commit: the parent check-in is corrupt\n") 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). fn commitNotRegular(s: cli.Streams, path: []const u8) cli.Exit { 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 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; } /// `zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME]` /// (contracts/amend.md B-amend-1..17). amend NEVER rewrites a check-in: it mints /// ONE control artifact whose T cards change how the target check-in DISPLAYS /// (comment/user/date/branch), which the crosslinker applies on the next read /// (repo-state §4/§5). History stays append-only — the original manifest is /// untouched (B-amend-5). The one durable write is the control artifact + log /// fsync; every validation (parse, NAME resolution, `--date` predicate, /// target-is-a-check-in) precedes it, so a refusal mints nothing. 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", 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.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); // 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. Assemble the CONTROL artifact and serialize it (artifact §5.2: exactly // D, T…, U, Z). The library owns card order, escaping, the Z self-check, // and framing. D is the amend time — the §5.3 supersede clock (B-amend-6); // U is the amender's env login (empty → `anonymous` on read, §4.7.17). var ts_buf: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(amb.now_epoch, &ts_buf); var m: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .control, .a = null, .b = null, .c = null, .d = .{ .timestamp = ts }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = null, .u = .{ .login = amb.user_env orelse "" }, .z = null, .w = null, .f = &.{}, .j = &.{}, .m = &.{}, .p = &.{}, .q = &.{}, .t = tags, }; defer m.deinit(); // frees the empty arena_state; card storage is `arena`'s const bytes = artifact.create(&m, gpa) catch return amendOom(streams); defer gpa.free(bytes); // Correctness self-check (safety builds only): the emitted bytes round-trip // to a control artifact. A failure means the CLI mis-populated a card — a bug // caught here, never shipped (mirrors commit's self-check). if (std.debug.runtime_safety) { var check = artifact.parse(gpa, bytes) catch |e| std.debug.panic("amend produced an unparseable control artifact: {s}", .{@errorName(e)}); defer check.deinit(); std.debug.assert(check.kind == .control); } // 6. Open the log read-write, store the control artifact, and durably commit — // the single fsync. Everything above is read-only or in-memory, so a crash // before this mints nothing; after it, the control is durable and // crosslinks on the next read (B-amend-5/7). The reported name is the // 64-hex of the uncompressed bytes. var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch return amendLogFail(streams, error.Storage); defer file.close(io); const log_st = file.stat(io) catch return amendLogFail(streams, error.Storage); var fs = storage.FileStorage.init(io, file, log_st.size); var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| return amendLogFail(streams, e); defer log.deinit(gpa); var cdigest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(bytes, &cdigest, .{}); var cname_buf: [64]u8 = undefined; const control_name = artifact.artifactName(.sha3_256, bytes, &cname_buf); _ = log.putArtifact(&cdigest, bytes, null) catch |e| return amendLogFail(streams, e); log.commit() 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(); } const top_usage = \\zit — a Fossil version-control system in Zig \\ \\usage: zit <command> [args] \\ \\commands: \\ init create an empty zit repository \\ add mark files to include in your next check-in \\ rm stop tracking files (undo add) \\ mv rename a tracked file \\ status show the working checkout state \\ diff compare managed files with the current check-in \\ extras list files in the checkout that zit isn't tracking \\ clean delete files in the checkout that zit isn't tracking \\ commit record a check-in from your tracked files \\ log show a check-in and its reachable parents \\ update materialize a check-in in the working tree \\ merge merge another check-in into the working tree \\ revert restore managed files to their checked-in contents \\ undo undo the last update or revert \\ redo reapply an undone change \\ leaves list the open leaves of the check-in DAG \\ timeline list every check-in, most recent first \\ branch list the repository's branches \\ tag list the tags in use, or those on a check-in \\ amend attach display overrides to a check-in \\ info resolve a name to a check-in and describe it \\ ls list the files of a check-in \\ cat emit a file's contents at a check-in \\ artifact print or describe a raw artifact by its hash \\ annotate show which check-in introduced each line of a file \\ bisect binary-search the check-in DAG for a good/bad boundary \\ serve serve this repository over HTTP for sync \\ clone copy a repository from a URL into a new directory \\ pull fetch new artifacts from a URL into this repository \\ push send this repository's artifacts to a URL \\ sync reconcile with a peer in both directions \\ remote show, set, or clear the default sync remote \\ pack repack the repository store into a compact new generation \\ version print the version \\ help show this help \\ ; const log_help = \\usage: zit log CHECKIN [PATH] \\ \\Show CHECKIN followed by every reachable parent check-in in depth-first \\P-card order. CHECKIN must be an exact 40- or 64-character lowercase \\hexadecimal artifact ID. Each check-in is shown once; a referenced parent \\missing from this repository is shown once as a phantom. \\ \\With PATH, show only the reachable check-ins that change PATH — where \\PATH's effective content differs from its primary parent's, including \\where PATH is added or deleted. PATH is an exact repo-relative path, not \\a pattern; a path that never changed yields no output. \\ \\options: \\ -h, --help show this help and exit \\ ; const update_help = \\usage: zit update CHECKIN \\ \\Materialize CHECKIN in the working tree. CHECKIN must be an exact 40- or \\64-character lowercase hexadecimal artifact ID. \\ \\options: \\ -h, --help show this help and exit \\ ; const merge_help = \\usage: zit merge NAME \\ \\Merge the check-in NAME into the working tree with a three-way merge and \\record it as a pending merge parent. NAME resolves like 'zit info' — a full \\or abbreviated artifact ID, a branch, or a tag. This does not create a \\check-in; run 'zit commit' to record the merge. Conflicts are written into \\the working files with conflict markers and listed on stderr — resolve them \\and commit, or run 'zit undo' to abandon the merge. \\ \\options: \\ -h, --help show this help and exit \\ ; const bisect_help = \\usage: zit bisect bad NAME | good NAME | next | status | reset \\ \\Binary-search the check-in DAG for the check-in that introduced a defect. \\Mark a known-bad and a known-good check-in; zit narrows the candidate span \\to the ancestors of the bad check-in that are not ancestors of any good one \\and moves the working tree to the midpoint for you to test. Repeat until one \\check-in remains — the first bad check-in. \\ \\subcommands: \\ bad NAME mark NAME as containing the defect \\ good NAME mark NAME as free of the defect \\ next move to (or re-announce) the current probe check-in \\ status show the marks, the current probe, and the remaining count \\ reset end the bisection and restore the starting check-in \\ \\NAME resolves like 'zit info' — a full or abbreviated artifact ID, a branch, \\or a tag. Marking moves the working tree through the update engine, so a move \\is refused over a dirty working tree and is recoverable with 'zit undo'. The \\bisection state lives in .zit-bisect and never enters the repository. \\ \\options: \\ -h, --help show this help and exit \\ ; const annotate_help = \\usage: zit annotate PATH \\ \\Show, for each line of PATH at the current check-out, the check-in that \\introduced that line and when. PATH is an exact repo-relative path to a \\tracked file; each output line is the introducing check-in's 10-character \\artifact-ID prefix, the ISO date it was recorded (UTC), then the file line. \\ \\Provenance is computed over the file's whole version history: a line is \\attributed to the oldest check-in that carries it. A line that arrived from \\a merged branch is credited to its origin on that branch, never to the merge \\check-in itself. Only committed content is annotated — uncommitted edits in \\the working file are not shown. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the annotation was written \\ 1 PATH is not tracked at the current check-out, its content is binary, \\ or this is not a zit repository \\ 2 usage error (a missing PATH, an unknown option, or a second positional) \\ ; const revert_help = \\usage: zit revert [PATH...] \\ \\Restore managed files to their contents at the current check-in, discarding \\working-tree edits. With no PATH, every managed file is reverted; name one \\or more paths to revert just those. \\ \\revert changes file contents only — it never changes which files are \\tracked. A managed file that the current check-in does not contain (added \\but never committed, or deleted at this check-in) is reported and left \\alone, never recreated. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the named files were reverted (or had nothing to restore) \\ 1 a path is not tracked, a file could not be written, the checkout is \\ unborn, or this is not a zit repository \\ 2 usage error (an unknown option) \\ ; const undo_help = \\usage: zit undo [-h|--help] \\ \\Undo the last working-file change made by 'zit update' or 'zit revert', \\restoring every affected file and the checkout record to their state before \\that change. Single level: only the most recent change can be undone; a second \\'zit undo' in a row has nothing left to undo. \\ \\Use 'zit redo' to reapply a change you just undid. A new update or revert \\clears the redo slot. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the change was undone \\ 1 nothing to undo, the journal is damaged, or this is not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; const redo_help = \\usage: zit redo [-h|--help] \\ \\Reapply the change you just undid with 'zit undo', restoring every affected \\file and the checkout record to their state after that change. Single level: \\redo is available only immediately after an undo; a new update or revert \\clears it. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the change was reapplied \\ 1 nothing to redo, the journal is damaged, or this is not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; const pack_help = \\usage: zit pack [-h|--help] \\ \\Repack this repository's store into a compact new generation, re-encoding \\full-form artifacts as deltas against their lineage (a check-in against its \\parent, a file against the same path in the parent check-in) and dropping \\superseded config records. Every artifact's content and name are preserved \\exactly; the on-disk layout is the only thing that changes. \\ \\pack streams the whole store, verifying every frame's checksum and that each \\reconstructed artifact still hashes to its name — so it doubles as a \\whole-store integrity check. The packed log is written to a temporary file \\and swapped into place atomically: a crash before the swap leaves the \\original untouched. It transiently needs room for both the old and new store. \\ \\options: \\ -h, --help show this help and exit \\ ; const leaves_help = \\usage: zit leaves [--all] [--no-cache] [-h|--help] \\ \\List the open leaves of the check-in DAG — check-ins with no child on the \\same branch that carry no active closed tag. Each line shows the check-in's \\full artifact ID and its branch. Leaves are ordered most-recent first; the \\leaf matching the current checkout is marked with a leading "*". \\ \\options: \\ --all also list closed leaves, each marked "(closed)" \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help and exit \\ ; const timeline_help = \\usage: zit timeline [-h|--help] \\ \\List every check-in in the repository, most recent first. Each line shows \\the check-in's short hash, its ISO timestamp, its branch, and the first \\line of its comment. A check-in that joins two or more lines of development \\is marked "M". Order is by event time descending, ties broken by artifact \\hash, so the listing is byte-identical across runs. \\ \\options: \\ -h, --help show this help and exit \\ ; const branch_help = \\usage: zit branch [--no-cache] [-h|--help] \\ \\List the repository's branches, one per line, ordered by name. Branches are \\derived from the active branch tags of the check-ins; the branch of the current \\checkout is marked with a leading "*". A branch whose most recent check-in is \\closed is marked "(closed)". \\ \\options: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help and exit \\ ; const tag_help = \\usage: zit tag list [CHECKIN] [--no-cache] \\ \\List tags. With no CHECKIN, print the tag names in use across the \\repository, one per line, sorted. With a CHECKIN, print the tags visible \\on that check-in and their values. \\ \\ ls alias for list \\ \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help \\ ; const serve_help = \\usage: zit serve [--port N] [-h|--help] \\ \\Serve this repository over HTTP so a peer can sync against it. Answers a \\POST to /xfer carrying an application/x-fossil body per the sync protocol, \\one request per connection, single-threaded. Binds 127.0.0.1 on port N \\(default 8080). Stop it with Ctrl-C. \\ \\options: \\ --port N listen on port N (default 8080) \\ -h, --help show this help and exit \\ ; const clone_help = \\usage: zit clone URL DIR [-h|--help] \\ \\Copy a repository served at URL into a new directory DIR. Creates DIR (and \\its `.zit`), opens an HTTP connection to URL, syncs the repository's \\artifacts down to convergence, and stores them so DIR is a usable repo. \\ \\URL is the peer's sync endpoint — an http:// or https:// address, used \\verbatim (the path is not rewritten). The clone is anonymous: no login. \\ \\options: \\ -h, --help show this help and exit \\ ; const pull_help = \\usage: zit pull [URL] [-h|--help] \\ \\Fetch artifacts from the repository served at URL into the current repository. \\Opens an HTTP connection to URL, announces what this repository already holds, \\requests everything it lacks, and stores the newly received artifacts so this \\repository converges toward the peer's content. \\ \\URL is the peer's sync endpoint — an http:// or https:// address, used \\verbatim (the path is not rewritten). The pull is anonymous: no login. With \\URL omitted the saved remote is used ('zit remote URL' saves one); a repo \\with no saved remote and no URL is an error. \\ \\options: \\ -h, --help show this help and exit \\ ; const push_help = \\usage: zit push [URL] [--user U [--password P]] [-h|--help] \\ \\Send this repository's artifacts to the repository served at URL. Opens an \\HTTP connection to URL, announces what this repository holds, and uploads \\every artifact the peer lacks until both sides converge. The peer stores the \\uploaded artifacts; nothing on this side changes. \\ \\URL is the peer's sync endpoint — an http:// or https:// address, used \\verbatim (the path is not rewritten). With URL omitted the saved remote is \\used ('zit remote URL' saves one); a repo with neither is an error. \\ \\Without --user the push is anonymous (no login card). With --user the push \\authenticates: the shared secret is derived from the password and the login \\card is signed with it. The password comes from --password, else the \\ZIT_PASSWORD environment variable, else a no-echo prompt. After a successful \\authenticated push the derived secret (never the password) is saved in the \\repository at 0600, so a later push with just --user needs no password. \\ \\options: \\ --user U authenticate as login U (sync §6) \\ --password P U's password (prompted without echo if omitted) \\ -h, --help show this help and exit \\ ; const sync_help = \\usage: zit sync [URL] [--user U [--password P]] [-h|--help] \\ \\Reconcile this repository with the one served at URL in BOTH directions in a \\single exchange: this side uploads every artifact the peer lacks and receives \\every artifact it lacks, until both converge. This is one session, not a pull \\followed by a push — sync history transfers both ways and never discards \\artifacts (so there is no force-push). \\ \\URL is the peer's sync endpoint — an http:// or https:// address, used \\verbatim (the path is not rewritten). With URL omitted the saved remote is \\used ('zit remote URL' saves one); a repo with neither is an error. \\ \\Without --user the sync is anonymous. With --user it authenticates for the \\upload exactly as 'zit push' does: the shared secret is derived from the \\password (from --password, else ZIT_PASSWORD, else a no-echo prompt) and, on \\success, saved in the repository at 0600 so a later --user needs no password. \\ \\options: \\ --user U authenticate as login U (sync §6) \\ --password P U's password (prompted without echo if omitted) \\ -h, --help show this help and exit \\ ; const remote_help = \\usage: zit remote [URL | --unset] [-h|--help] \\ \\Show, set, or clear the default sync remote — the peer URL that 'zit sync', \\'zit pull', and 'zit push' use when you do not name one. With no argument, \\print the saved remote (or 'no remote set'). With URL, save it. With --unset, \\clear it. \\ \\There is a single default remote, not a named-remote registry. URL is stored \\verbatim and used anonymously — never put a credential in it; authenticate a \\push or sync with --user instead. \\ \\options: \\ --unset clear the saved remote \\ -h, --help show this help and exit \\ ; const info_help = \\usage: zit info NAME [--no-cache] \\ \\Resolve NAME to a single check-in and describe it. NAME may be a full \\40- or 64-character artifact ID, a unique ID prefix, a branch or tag name, \\or one of the reserved forms (tip, rid:, tag:, root:, start:, merge-in:). \\Resolution follows recency, not open-leaf status: a name can resolve to a \\closed or non-leaf check-in that 'zit leaves' would not list. \\ \\options: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help and exit \\ ; const amend_help = \\zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME] \\ \\Attach display overrides to a check-in by minting one control artifact. \\History is never rewritten; the original check-in is untouched. \\ \\ --comment TEXT override the displayed comment \\ --user NAME override the displayed author \\ --date ISO override the displayed date (YYYY-MM-DDTHH:MM:SS) \\ --branch NAME move the check-in (and its descendants) to branch NAME \\ -h, --help show this help \\ \\At least one override flag is required. \\ ; const ls_help = \\usage: zit ls [VERSION] [--no-cache] \\ \\List the files of a check-in, one per line as `<blob-id> <filename>`, in \\canonical filename order. VERSION selects the check-in the same way 'zit \\info' does — a full 40- or 64-character artifact ID, a unique ID prefix, a \\branch or tag name, or a reserved form (tip, rid:, tag:, root:, start:, \\merge-in:). With VERSION omitted, the current checkout is listed; an unborn \\checkout has nothing to list and is an error. \\ \\The listing is the effective file set: a delta check-in is merged with its \\baseline and deleted files are omitted. \\ \\options: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help and exit \\ ; const cat_help = \\usage: zit cat FILE [VERSION] [--no-cache] \\ \\Write the contents of FILE at a check-in to standard output, verbatim — no \\trailing newline is added and binary content passes through unchanged. FILE \\is a pathname within the check-in; VERSION selects the check-in the same way \\'zit info' does — a full 40- or 64-character artifact ID, a unique ID prefix, \\a branch or tag name, or a reserved form (tip, rid:, tag:, root:, start:, \\merge-in:). With VERSION omitted, the current checkout is used; an unborn \\checkout has nothing to read and is an error. \\ \\FILE is resolved against the effective file set: in a delta check-in the \\file's own entry wins over the baseline, and a file deleted at this check-in \\is reported as not found. The emitted bytes are verified to match the file's \\recorded hash. \\ \\options: \\ --no-cache bypass the cache and re-derive from the full log, \\ re-verifying whole-log integrity (the cache is refreshed) \\ -h, --help show this help and exit \\ ; const artifact_help = \\usage: zit artifact HASH [--raw] \\ \\Describe or dump a single stored artifact addressed by its hash. HASH is a full \\or abbreviated (>=4 character) lowercase hexadecimal artifact ID, resolved \\against every stored artifact — file blobs included. Branch and tag names are \\not accepted; use 'zit info' for those. \\ \\Without --raw, print a summary: the full hash, the artifact's size, its type, \\and — for a structural artifact — one line per card and whether its Z \\self-checksum verifies. A blob reports its size and whether it is binary. \\ \\With --raw, write the artifact's exact stored bytes to standard output, \\verbatim — no trailing newline is added and binary content passes through \\unchanged. Hashing those bytes reproduces HASH. \\ \\options: \\ --raw write the exact stored bytes instead of a summary \\ -h, --help show this help and exit \\ ; const diff_help = \\usage: zit diff [-h|--help] \\ \\Compare the managed working-tree files with the current check-in. Output is \\metadata only, sorted by pathname: A for additions, M for content or \\executable-mode changes, and D for files removed from management or the \\working tree. \\ \\options: \\ -h, --help show this help and exit \\ ; /// Verbatim B-init-11 help text. Model-honest wording ("check-in", "trunk", /// "unborn", "root check-in", "SHA3-256 by default"). Ends in exactly one /// newline. The e2e suite verifies stdout equals an independent transcription /// of the contract, so drift here is caught. const init_help = \\usage: zit init [-q|--quiet] [--] [directory] \\ \\Create an empty zit repository in <directory> (default: the current \\directory). Writes two files there: \\ \\ .zit the repository — a single, self-contained content log \\ .zit-checkout the working-state record (branch trunk, no check-in yet) \\ \\The new repository has no check-ins: the default branch 'trunk' is unborn \\until your first `zit commit`, which records the root check-in. Artifacts \\are named with SHA3-256 by default. \\ \\If <directory> does not exist it is created. \\ \\options: \\ -q, --quiet do not print the confirmation line \\ -h, --help show this help and exit \\ \\exit status: \\ 0 repository created \\ 1 a repository already exists, or the path could not be initialized \\ 2 usage error (unknown option or extra argument) \\ ; /// Verbatim B-add-13 help text (contracts/add.md §Help). Model-honest wording /// ("managed", "no staging area", "check-in", "trunk", "unborn", "zit rm"). /// Ends in exactly one newline. The e2e suite compares stdout against an /// independent transcription of this block, so drift is caught. const add_help = \\usage: zit add [-n|--dry-run] [-q|--quiet] [--] <path>... \\ \\Mark one or more working-tree files as managed, so they are included in \\your next check-in. zit has no staging area: `add` records only *which* \\files to track — their contents are read from the working tree when you \\run `zit commit`, not now. Re-adding an already-tracked file does nothing. \\ \\Files stay tracked until `zit rm` forgets them. You can add files before \\the first check-in (the default branch 'trunk' is unborn until then). \\ \\Only individual files are accepted; naming a directory is an error. \\ \\options: \\ -n, --dry-run show what would be added; change nothing \\ -q, --quiet do not print the added files \\ -h, --help show this help and exit \\ \\exit status: \\ 0 all named files are now tracked (or already were) \\ 1 a file could not be added (missing, a directory, outside the repo), \\ or this is not a zit repository \\ 2 usage error (no files given, or an unknown option) \\ ; /// Verbatim B-rm-13 help text (contracts/rm.md §Help). Model-honest wording /// ("tracking", "managed set", "no staging area", "check-in", and the explicit /// contrast with git's delete-by-default). Ends in exactly one newline. The e2e /// suite compares stdout against an independent transcription of this block. const rm_help = \\usage: zit rm [-n|--dry-run] [--hard] [-q|--quiet] [--] <path>... \\ \\Stop tracking one or more files: remove their paths from the managed set, \\so your next check-in records their deletion. zit has no staging area, so \\this takes effect when you run `zit commit`, not now. \\ \\By default your working copy is left on disk untouched -- `rm` only forgets \\the file. Pass --hard to also delete the file from disk. (git deletes by \\default; zit keeps your file unless you ask for --hard.) \\ \\Removing a path that is not tracked is an error. Only individual files are \\accepted; naming a directory is an error. \\ \\options: \\ -n, --dry-run show what would be removed; change nothing \\ --hard also delete the working-tree file from disk \\ -q, --quiet do not print the removed files \\ -h, --help show this help and exit \\ \\exit status: \\ 0 all named files are no longer tracked (and, with --hard, deleted) \\ 1 a file could not be removed (not tracked, a directory, outside the \\ repo, or --hard could not delete it), or this is not a zit repository \\ 2 usage error (no files given, or an unknown option) \\ ; /// Verbatim help text (contracts/mv.md §Help). Model-honest wording ("tracked", /// "check-in", "recorded", the explicit no-clobber / no-force / no-undo posture). /// Ends in exactly one newline. The e2e suite compares stdout against an /// independent transcription of this block, so drift is caught. const mv_help = \\usage: zit mv OLD NEW \\ \\Rename a tracked file: move the working file from OLD to NEW on disk, swap \\the tracked-name entry, and record the rename so your next `zit commit` \\records NEW with OLD as its former name. \\ \\OLD must be tracked and present in the working tree; NEW must not already be \\tracked or exist on disk. Renaming never overwrites -- a collision is \\refused, and there is no --force. mv takes exactly two operands; it is never \\a glob or multi-file move, and it does not rename directories. \\ \\mv is not undone by `zit undo`; to reverse a rename, run `zit mv NEW OLD`. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the file was renamed \\ 1 OLD is not tracked or missing, NEW is already tracked or on disk, or \\ this is not a zit repository \\ 2 usage error (wrong operand count, or an unknown option) \\ ; /// Verbatim B-status-16 help text (contracts/status.md §Help). Model-honest /// wording ("check-in", "trunk", "unborn", "tracked", "artifact hash" — never /// git's "index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary /// (DX D2) rather than hiding it. Ends in exactly one newline. The e2e suite /// compares stdout against an independent transcription of this block, so drift /// is caught. const status_help = \\usage: zit status [-h|--help] \\ \\Show the state of the working copy: the current branch, the current \\check-in (if any), and the state of your tracked files. \\ \\zit has no staging area. `status` reports the files you marked with \\`zit add` and how each one stands in the working tree: \\ \\ added <path> tracked and new — it will enter your next check-in \\ missing <path> tracked, but not present in the working tree \\ \\Until your first `zit commit` the branch 'trunk' is unborn: there is no \\check-in yet, so every tracked file is reported as 'added' (it will form \\the root check-in). The current check-in, once one exists, is shown as its \\full artifact hash. \\ \\Not yet reported (these arrive with `zit commit` / `zit diff`): edits to \\already-committed files, files deleted from a check-in, and untracked \\("extra") files in the working tree. \\ \\options: \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the working-copy state was reported (with or without changes) \\ 1 this is not a zit repository, or its state could not be read \\ 2 usage error (an unknown option or an unexpected argument) \\ ; /// Verbatim `zit extras` help (contracts/extras.md §Help). The house /// `parseNoPositionalArgs`-verb help shape (status/diff/timeline). Ends in /// exactly one newline; the e2e suite compares stdout against an independent /// transcription, so any drift is caught. const extras_help = \\usage: zit extras [-h|--help] \\ \\List every file in the checkout that zit is not tracking — each on-disk file \\under the checkout root that is neither a tracked file nor a zit control file. \\Paths are shown relative to the checkout root, one per line, sorted. Files in \\subdirectories are included; directories themselves are not. Symlinks are \\listed but never followed. With nothing untracked, the output is empty. \\ \\options: \\ -h, --help show this help and exit \\ ; /// Verbatim `zit clean` help (contracts/clean.md §Help). Names the destructive, /// non-undoable, non-interactive posture up front and the `--dry-run`-wins /// precedence. Ends in exactly one newline; the e2e suite compares stdout /// against an independent transcription, so any drift is caught. const clean_help = \\usage: zit clean [--dry-run] [--force] \\ \\Delete every file in the checkout that zit is not tracking — the same set \\`zit extras` lists. This is destructive and cannot be undone: `zit undo` does \\not recover a clean. \\ \\By default clean refuses and deletes nothing: it reports how many untracked \\files would be deleted and stops. Pass --force to delete them, or --dry-run to \\preview the exact list first. clean never prompts. \\ \\Tracked files and zit's control files are never touched. Only files are \\deleted; directories left empty are not pruned. \\ \\options: \\ --dry-run list what would be deleted, one path per line; delete nothing \\ --force delete the untracked files \\ -h, --help show this help and exit \\ \\If both --dry-run and --force are given, --dry-run wins and nothing is deleted. \\ \\exit status: \\ 0 nothing to delete, a --dry-run preview, or every file deleted \\ 1 refused (no --force), a file could not be deleted, or not a zit repository \\ 2 usage error (an unknown option or an unexpected argument) \\ ; /// Verbatim B-commit-20 help text (contracts/commit.md §Help). Model-honest /// wording ("check-in", "root check-in", "trunk", "artifact hash", "tracked", /// "no staging area", "immutable", "no amend/--force" — never git's /// "commit"/"index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary /// (DX D2). Ends in exactly one newline. The e2e suite compares stdout against /// an independent transcription of this block, so drift is caught. const commit_help = \\usage: zit commit -m <message> [--user <login>] [--branch <name>] [--close] \\ [-q|--quiet] [--] [<path>...] \\ \\Record a check-in: save the current contents of your tracked files as a \\new, permanent point in history on the current branch. \\ \\zit has no staging area. `commit` reads each tracked file straight from \\the working tree right now — there is nothing to stage first. With no \\paths, every tracked file is captured; name one or more paths to commit \\just those (the rest of the tree is carried forward unchanged). \\ \\The first commit in a new repository creates the root check-in and starts \\the default branch 'trunk'. Each check-in is named by its full artifact \\hash (SHA3-256). \\ \\--branch <name> starts a new branch at this check-in: the check-in moves \\onto <name> and later commits inherit it. <name> must not already name an \\open branch, and there must be a parent to fork from. --close marks this \\check-in a closed leaf, so it no longer shows as an open head of its branch. \\ \\History is immutable: a check-in is never rewritten. There is no amend and \\no --force; to undo a file, change it and commit again. To stop tracking a \\file (and record its deletion), use `zit rm` then commit. \\ \\A check-in message is required (-m). Only regular files are captured. \\ \\options: \\ -m, --message <message> the check-in comment (required) \\ --user <login> author to record (default: $USER) \\ --branch <name> start a new branch at this check-in \\ --close mark this check-in a closed leaf \\ -q, --quiet do not print the check-in line \\ -h, --help show this help and exit \\ \\exit status: \\ 0 the check-in was created \\ 1 nothing to commit, a tracked file is missing, an empty message, an \\ existing branch name, or this is not a zit repository \\ 2 usage error (no message, or an unknown option) \\ ; test { // Pull the CLI harness's inline unit tests into `zig build test`. The // repo-view loader and its cache now live in the `zit` module and aggregate // through root.zig, so they are not re-imported here. _ = @import("cli.zig"); _ = @import("transport.zig"); _ = @import("serve.zig"); _ = @import("sync_driver.zig"); _ = @import("sync_driver_test.zig"); // The pure three-way merge engine behind `zit merge` (contracts/merge.md). // It imports only std, so its inline tests ride the CLI test binary here // (no build.zig change) exactly as `extras`/`undo` ride their importers. _ = @import("merge3.zig"); // The pure per-line provenance engine behind `zit annotate` // (contracts/annotate.md). Imports only std + merge3, so its inline tests // ride the CLI test binary here the same way. _ = @import("annotate.zig"); } /// A fixed `Ambient` for the in-process dispatch tests: a pinned epoch (no wall /// clock) and no environment author. Verbs other than `commit` ignore it; the /// commit dispatch tests that reach assembly pin their own values via the e2e /// harness (the real binary), so this constant is only the argument shape. const test_amb: cli.Ambient = .{ .now_epoch = 0, .user_env = null }; // --- add error-mapper unit test (B-add-17; pure switch) -------------------- test "add B-add-17: ManagedError maps to the contract messages and exit 1" { const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .arg = "", .want = "zit add: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .arg = "", .want = "zit add: cannot update tracked files: input/output error\n" }, .{ .err = error.TooLong, .arg = "deep/file", .want = "zit add: 'deep/file': path is too long to track\n" }, .{ .err = error.TooMany, .arg = "x", .want = "zit add: too many tracked files\n" }, .{ .err = error.OutOfMemory, .arg = "x", .want = "zit add: out of memory\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; const code = addFail(s, c.arg, c.err); try std.testing.expectEqual(cli.Exit.failure, code); try std.testing.expectEqualStrings(c.want, w.buffered()); } } // --- rm error-mapper unit test (B-rm-17; pure switch) ---------------------- test "rm B-rm-17: ManagedError maps to the contract messages and exit 1" { const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .arg = "", .want = "zit rm: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .arg = "", .want = "zit rm: cannot update tracked files: input/output error\n" }, .{ .err = error.TooLong, .arg = "deep/file", .want = "zit rm: 'deep/file': path is too long to track\n" }, .{ .err = error.TooMany, .arg = "x", .want = "zit rm: too many tracked files\n" }, .{ .err = error.OutOfMemory, .arg = "x", .want = "zit rm: out of memory\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; const code = rmFail(s, c.arg, c.err); try std.testing.expectEqual(cli.Exit.failure, code); try std.testing.expectEqualStrings(c.want, w.buffered()); } } // --- status error-mapper unit tests (B-status-10/-11; pure switches) ------- test "status B-status-10: CheckoutError maps to the contract messages and exit 1" { const Case = struct { err: checkout.CheckoutError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .want = "zit status: cannot read checkout state: .zit-checkout is corrupt\n" }, .{ .err = error.Storage, .want = "zit status: cannot read checkout state: input/output error\n" }, }; for (cases) |c| { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, checkoutFail(s, "status", c.err)); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "status B-status-10: a vanished checkout record renders the missing message and exit 1" { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, checkoutMissing(s, "status")); try std.testing.expectEqualStrings( "zit status: cannot read checkout state: .zit-checkout is missing\n", w.buffered(), ); } test "status B-status-11: ManagedError maps to the contract messages and exit 1" { const Case = struct { err: managed.ManagedError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .want = "zit status: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .want = "zit status: cannot read tracked files: input/output error\n" }, .{ .err = error.TooLong, .want = "zit status: cannot read tracked files: a tracked path is too long\n" }, .{ .err = error.TooMany, .want = "zit status: cannot read tracked files: too many tracked files\n" }, .{ .err = error.OutOfMemory, .want = "zit status: out of memory\n" }, }; for (cases) |c| { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, managedFail(s, "status", c.err)); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "status B-status-9: stateOom renders the out-of-memory message and exit 1" { var buf: [64]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, stateOom(s, "status")); try std.testing.expectEqualStrings("zit status: out of memory\n", w.buffered()); } // --- status dispatch: the io/alloc-free paths, driven in-process ----------- // // `--help` and the two usage errors touch neither the filesystem nor the // allocators, so they run through `run()` over fixed-buffer writers — the same // technique the unknown-command dispatch tests use — with the exact argv. /// Drives `run()` for `zit status <tail...>` over fixed-buffer writers, /// returning the exit code and the captured stdout/stderr (valid until the next /// call). Sized to hold the whole `status_help` block on stdout. fn runStatusCapture(tail: []const []const u8) struct { code: cli.Exit, out: []const u8, err: []const u8 } { const S = struct { var out_buf: [2048]u8 = undefined; var err_buf: [512]u8 = undefined; }; var out = Io.Writer.fixed(&S.out_buf); var err = Io.Writer.fixed(&S.err_buf); const streams: cli.Streams = .{ .out = &out, .err = &err }; var argv_buf: [8][]const u8 = undefined; argv_buf[0] = "zit"; argv_buf[1] = "status"; for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src; const code = run( std.testing.allocator, std.testing.io, std.testing.allocator, argv_buf[0 .. 2 + tail.len], streams, test_amb, ); return .{ .code = code, .out = out.buffered(), .err = err.buffered() }; } test "status B-status-12: an unknown option is a usage error (exit 2, stderr, empty stdout)" { const r = runStatusCapture(&.{"-x"}); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit status: unknown option '-x'\ntry 'zit status --help'\n", r.err, ); } test "status B-status-13: an unexpected argument is a usage error (exit 2, stderr, empty stdout)" { const r = runStatusCapture(&.{"foo"}); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit status: unexpected argument 'foo'\ntry 'zit status --help'\n", r.err, ); } test "status B-status-14: --help prints the help to stdout (exit 0, empty stderr)" { const r = runStatusCapture(&.{"--help"}); try std.testing.expectEqual(cli.Exit.ok, r.code); try std.testing.expectEqualStrings(status_help, r.out); try std.testing.expectEqualStrings("", r.err); } test "status B-status-16 DX-D4: 'zit help status' routes to the verb's own help" { // `run()`'s help arm touches neither io nor the allocators, so the topic // routing is driven in-process; the stdout must be byte-identical to // `status --help` (the DX-D4 fix: `zit help status` no longer prints the // general command list that implied the verb was absent). var out_buf: [2048]u8 = undefined; var err_buf: [256]u8 = undefined; var out = Io.Writer.fixed(&out_buf); var err = Io.Writer.fixed(&err_buf); const streams: cli.Streams = .{ .out = &out, .err = &err }; const code = run( std.testing.allocator, std.testing.io, std.testing.allocator, &.{ "zit", "help", "status" }, streams, test_amb, ); try std.testing.expectEqual(cli.Exit.ok, code); try std.testing.expectEqualStrings(status_help, out.buffered()); try std.testing.expectEqualStrings("", err.buffered()); } test "init B-init-3: default hash_kind is SHA3-256 (= 2)" { // Guards the byte the header stores for a fresh repo (artifact §7.2-m2). try std.testing.expectEqual(@as(u8, 2), @intFromEnum(zit.artifact.defaultPolicy(false, false))); } // --- unknown-command dispatch (DX D2/D4), verified through `run()` ---------- // // `run()` on an unknown verb touches neither io nor the allocators, so these // drive it in-process over fixed-buffer writers — no subprocess needed — and // assert the same exit-2 / stderr-only contract the e2e suite holds for init. /// Runs `zit <verb>` through `run()`, returning the exit code plus the captured /// stdout/stderr slices (valid until the next call — backed by static buffers, /// so a test uses one invocation at a time). const RunCapture = struct { code: cli.Exit, out: []const u8, err: []const u8, var out_buf: [256]u8 = undefined; var err_buf: [512]u8 = undefined; fn run_verb(verb: []const u8) RunCapture { return run_argv(&.{ "zit", verb }); } /// Like `run_verb` but with the full argv (post-"zit" tokens included), for /// verbs whose diagnostic depends on an option. Only safe for control paths /// that return before `discoverRoot` (which allocates into the arena — here /// `testing.allocator` — and is freed en masse by a real arena in `main`). fn run_argv(argv: []const []const u8) RunCapture { var out = Io.Writer.fixed(&out_buf); var err = Io.Writer.fixed(&err_buf); const streams: cli.Streams = .{ .out = &out, .err = &err }; const code = run(std.testing.allocator, std.testing.io, std.testing.allocator, argv, streams, test_amb); return .{ .code = code, .out = out.buffered(), .err = err.buffered() }; } }; /// Asserts a git-reflex `verb` renders its teaching line: exit 2, stdout empty, /// stderr naming the verb, stating it is not a zit command, and containing the /// lesson keyword `teaches`. fn expectReflex(verb: []const u8, teaches: []const u8) !void { const r = RunCapture.run_verb(verb); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, verb) != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "is not a zit command") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, teaches) != null); } test "cli D2 B-init-12: 'rebase' teaches immutable history, exit 2 stderr-only" { try expectReflex("rebase", "history is immutable"); } test "cli amend B-amend-12: 'amend' dispatches to the real handler, not the git-reflex lesson" { // amend is a real verb now (removed from `reflex_table`): `zit amend` with no // NAME is amend's OWN usage error, never the immutable-history reflex lesson — // teaching against a fossil-semantics amend with that lesson would conflate it // with git's history-rewriting amend. const r = RunCapture.run_verb("amend"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, "is not a zit command") == null); try std.testing.expect(std.mem.indexOf(u8, r.err, "no name given") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "try 'zit amend --help'") != null); } test "cli D2 B-init-12: 'reset' teaches immutable history, exit 2 stderr-only" { try expectReflex("reset", "history is immutable"); } test "cli D2 B-init-12: 'filter-branch' teaches immutable history, exit 2 stderr-only" { try expectReflex("filter-branch", "history is immutable"); } test "cli D2 B-init-12: 'checkout' points at 'zit update', exit 2 stderr-only" { try expectReflex("checkout", "zit update"); } test "cli push: an unknown option is a usage error before any I/O (real verb, dispatched)" { // `push` is a real handler, not a git-reflex redirect. Under M4 an omitted URL // is no longer an immediate usage error (it falls back to the saved remote), // but a malformed option is still rejected by the arg parser before any I/O — // exit 2, the diagnostic on stderr, empty stdout — proving real dispatch. const r = RunCapture.run_argv(&.{ "zit", "push", "--nope" }); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit push: unknown option '--nope'\ntry 'zit push --help'\n", r.err, ); } test "cli pull: an unknown option is a usage error before any I/O (real verb, dispatched)" { // `pull` is a real handler, not a git-reflex redirect. Under M4 an omitted URL // falls back to the saved remote rather than erroring immediately; a malformed // option is still rejected before any I/O — exit 2, stderr-only. const r = RunCapture.run_argv(&.{ "zit", "pull", "--nope" }); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit pull: unknown option '--nope'\ntry 'zit pull --help'\n", r.err, ); } test "serve §3.3: a pushed file card is persisted durably into the served repository's log" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A fresh, empty on-disk repository (header only) — the "server" a peer pushes // new content into. Built through the real LogStore, not seeded from fossil. const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); { var f = try tmp.dir.createFile(io, repo_filename, .{ .read = true, .exclusive = true }); defer f.close(io); var fs = storage.FileStorage.init(io, f, 0); var seed = try logstore.LogStore.open(gpa, fs.storageFile(), hash_kind); defer seed.deinit(gpa); try seed.commit(); } // A pushed full-content file card for a 64-hex (SHA3-256 width) artifact the // server lacks (sync §7.1: `file HASH SIZE\n` + payload; §4.1 zlib container). // The name is the artifact's real SHA3-256 (computed by our own hasher, not // fossil) so the server's §7.7 commit-hook recompute accepts it. const push_body = "pushed artifact bytes\n"; // 22 bytes var name_buf: [64]u8 = undefined; const push_hash = zit.artifact.artifactName(.sha3_256, push_body, &name_buf); const request_plain = try std.fmt.allocPrint(gpa, "pragma client-version 20000\nfile {s} {d}\n{s}", .{ push_hash, push_body.len, push_body }); defer gpa.free(request_plain); const request = try zit.container.encode(gpa, request_plain, .default); defer gpa.free(request); var digest_buf: [digest_len]u8 = undefined; const push_digest = decodeWireName(push_hash, &digest_buf).?; const server = zit.sync.Server{ .version = serve_mod.server_version }; // Answer the push twice through the real `serve.Responder` seam over a SINGLE // open handle (§3.8): the second answer re-reads the current on-disk log from // the same handle and sees the first push's frame, so it is an idempotent // re-push (content is addressed by hash — no dup frame). A stale startup // snapshot would instead re-append the artifact; this handle is never reopened. { var file = try tmp.dir.openFile(io, repo_filename, .{}); defer file.close(io); var ctx = ServeCtx{ .io = io, .root_dir = tmp.dir, .file = file, .server = &server }; for (0..2) |_| { const reply = try serveAnswer(&ctx, gpa, request); gpa.free(reply.body); } } // THE PROOF: re-open the log from disk in a fresh handle; the pushed artifact // is present with its exact content — durably stored, not discarded — and // appears exactly once (the idempotent re-push added no second frame). var file = try tmp.dir.openFile(io, repo_filename, .{}); defer file.close(io); const st = try file.stat(io); var fs = storage.FileStorage.init(io, file, st.size); var log = try logstore.LogStore.openReadOnly(gpa, fs.storageFile()); defer log.deinit(gpa); const rid = log.lookup(push_digest) orelse return error.PushedArtifactMissing; const stored = (try log.content(gpa, rid)).?; defer gpa.free(stored); try std.testing.expectEqualStrings(push_body, stored); const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); var artifacts: usize = 0; for (rids) |r| { if (log.kindOf(r) != .config) artifacts += 1; } try std.testing.expectEqual(@as(usize, 1), artifacts); } // --- D4: client verbatim retention + the §7.7 persist hook ------------------ /// Create a fresh, empty (header-only, committed) `.zit` in `dir` so a /// `persistStore` test can persist into it, exactly like a just-cloned repo. fn d4SeedEmptyRepo(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); var f = try dir.createFile(io, repo_filename, .{ .read = true, .exclusive = true }); defer f.close(io); var fs = storage.FileStorage.init(io, f, 0); var seed = try logstore.LogStore.open(gpa, fs.storageFile(), hash_kind); defer seed.deinit(gpa); try seed.commit(); } /// Reopen the persisted `.zit` in `dir` for reading. `fs_out` is a caller-owned /// `FileStorage` that MUST outlive the returned `LogStore` (its `storageFile()` /// vtable borrows `fs_out`); the caller closes `fs_out.file` and deinits `log`. fn d4OpenLog(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, fs_out: *storage.FileStorage) !logstore.LogStore { const file = try dir.openFile(io, repo_filename, .{}); const st = try file.stat(io); fs_out.* = storage.FileStorage.init(io, file, st.size); return try logstore.LogStore.open(gpa, fs_out.storageFile(), 0); } test "sync §7.7: persistStore recomputes the artifact hash and aborts on a lying container, writing no frame" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // A received full cfile whose wire NAME does not name its content (§7.7 — // a clone skips the transfer-layer pre-storage check, so a lying container // reaches the persist commit hook). var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const content = "content that the wire name does not actually name\n"; const lying_name = "0" ** 64; // not SHA3-256(content) const rid = try s.put(lying_name, content); const container = try zit.container.encode(gpa, content, .default); defer gpa.free(container); try s.retain(rid, .{ .full = container }); // §7.7 L1336-1338 — recompute and abort before ANY frame is written. try std.testing.expectError(error.Corrupt, persistStore(gpa, io, tmp.dir, &ms)); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); var digest_buf: [digest_len]u8 = undefined; const digest = decodeWireName(lying_name, &digest_buf).?; try std.testing.expect(log.lookup(digest) == null); // no frame landed } test "sync §7.2/§4.1: persistStore stores received full and delta wire forms verbatim; a re-serve preserves the delta container byte-for-byte" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // Model a clone reply in §8.2 RID order: a full base cfile then a delta // cfile against it. Names are the real SHA3-256 so the §7.7 hook accepts. var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const base = "the base artifact with a long shared run so the delta stays a compact copy payload distinct in size from the reconstructed target"; const target = base ++ " plus a small trailing mutation"; var bn: [64]u8 = undefined; var tn: [64]u8 = undefined; const base_name = zit.artifact.artifactName(.sha3_256, base, &bn); const target_name = zit.artifact.artifactName(.sha3_256, target, &tn); const base_container = try zit.container.encode(gpa, base, .default); defer gpa.free(base_container); const base_rid = try s.put(base_name, base); try s.retain(base_rid, .{ .full = base_container }); const raw = try zit.delta.create(gpa, base, target); defer gpa.free(raw); const delta_container = try zit.container.encode(gpa, raw, .default); defer gpa.free(delta_container); const ts = try zit.delta.targetSize(raw); const delta_rid = try s.put(target_name, target); try s.retain(delta_rid, .{ .delta = .{ .src = base_name, .container = delta_container, .target_size = ts } }); const written = try persistStore(gpa, io, tmp.dir, &ms); try std.testing.expectEqual(@as(usize, 2), written); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); // The target persisted as a `.delta` frame that reconstructs to the original. var tdig: [digest_len]u8 = undefined; const target_digest = decodeWireName(target_name, &tdig).?; const trid = log.lookup(target_digest).?; try std.testing.expectEqual(logstore.FrameKind.delta, log.kindOf(trid)); const got = (try log.content(gpa, trid)).?; defer gpa.free(got); try std.testing.expectEqualStrings(target, got); // Clone-of-clone: a re-serve snapshot emits the SAME delta container bytes // (§7.2 retransmitted verbatim) — the compactness survives the second hop. var serve_ms = repostate.MemStore.init(gpa); defer serve_ms.deinit(); try loadContentStore(gpa, &log, &serve_ms); const serve_rid = serve_ms.store().lookup(target_name).?; const wf = serve_ms.store().wire(serve_rid).?; try std.testing.expect(wf.delta != null); try std.testing.expectEqualSlices(u8, delta_container, wf.container); try std.testing.expectEqual(@as(u64, target.len), wf.delta.?.target_size); } test "sync §3.3: persistStore falls back to full form when a retained delta's basis is absent, never a phantom-based delta" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // Only the delta is present; its basis was never received/stored (a legal // §3.3 outcome that must not persist a delta over a phantom base). var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const base = "an absent basis whose long shared run would make a compact delta if it were here at all"; const target = base ++ " mutated once"; var bn: [64]u8 = undefined; var tn: [64]u8 = undefined; const base_name = zit.artifact.artifactName(.sha3_256, base, &bn); const target_name = zit.artifact.artifactName(.sha3_256, target, &tn); const raw = try zit.delta.create(gpa, base, target); defer gpa.free(raw); const dc = try zit.container.encode(gpa, raw, .default); defer gpa.free(dc); const ts = try zit.delta.targetSize(raw); const rid = try s.put(target_name, target); try s.retain(rid, .{ .delta = .{ .src = base_name, .container = dc, .target_size = ts } }); const written = try persistStore(gpa, io, tmp.dir, &ms); try std.testing.expectEqual(@as(usize, 1), written); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); // The target persisted as a FULL (.zlib) frame — the fallback, not a delta. var tdig: [digest_len]u8 = undefined; const target_digest = decodeWireName(target_name, &tdig).?; const trid = log.lookup(target_digest).?; try std.testing.expectEqual(logstore.FrameKind.zlib, log.kindOf(trid)); const got = (try log.content(gpa, trid)).?; defer gpa.free(got); try std.testing.expectEqualStrings(target, got); // No phantom basis frame was minted for the absent DELTASRC. var bdig: [digest_len]u8 = undefined; const base_digest = decodeWireName(base_name, &bdig).?; try std.testing.expect(log.lookup(base_digest) == null); } test "sync §7.7/§3.3: persistStore stores a retained delta chain up to the read bound and falls back to full form beyond it" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const cap: usize = logstore.max_delta_chain; // the READ bound (32) const n: usize = cap + 2; // links 0..cap+1: link cap+1's basis sits at depth cap // A chain grown one byte per link so each delta is tiny. Link i's basis is // link i-1 (§8.2 RID order); the persist path stores a `.delta` iff the base // depth is under `max_delta_chain`, so link cap+1 falls back to full form. var content: std.ArrayList(u8) = .empty; defer content.deinit(gpa); try content.appendSlice(gpa, "chain-root-artifact-with-a-long-shared-run-for-compact-forward-deltas"); var prev_name: [64]u8 = undefined; var prev_name_len: usize = 0; var deep_name: [64]u8 = undefined; // link cap (deepest stored delta) var deep_len: usize = 0; var last_name: [64]u8 = undefined; // link cap+1 (the fallback) var last_len: usize = 0; var i: usize = 0; while (i < n) : (i += 1) { try content.append(gpa, 'a' + @as(u8, @intCast(i % 26))); const cur = content.items; const prev = cur[0 .. cur.len - 1]; var nbuf: [64]u8 = undefined; const name = zit.artifact.artifactName(.sha3_256, cur, &nbuf); const rid = try s.put(name, cur); if (i == 0) { const c = try zit.container.encode(gpa, cur, .default); defer gpa.free(c); try s.retain(rid, .{ .full = c }); } else { const raw = try zit.delta.create(gpa, prev, cur); defer gpa.free(raw); const c = try zit.container.encode(gpa, raw, .default); defer gpa.free(c); const tsz = try zit.delta.targetSize(raw); try s.retain(rid, .{ .delta = .{ .src = prev_name[0..prev_name_len], .container = c, .target_size = tsz } }); } @memcpy(prev_name[0..name.len], name); prev_name_len = name.len; if (i == cap) { @memcpy(deep_name[0..name.len], name); deep_len = name.len; } if (i == n - 1) { @memcpy(last_name[0..name.len], name); last_len = name.len; } } _ = try persistStore(gpa, io, tmp.dir, &ms); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); // Link cap is the deepest `.delta` the write bound allows; link cap+1 fell // back to a full `.zlib` frame yet still reconstructs (never a Corrupt read). var ddig: [digest_len]u8 = undefined; const deep_rid = log.lookup(decodeWireName(deep_name[0..deep_len], &ddig).?).?; try std.testing.expectEqual(logstore.FrameKind.delta, log.kindOf(deep_rid)); var ldig: [digest_len]u8 = undefined; const last_rid = log.lookup(decodeWireName(last_name[0..last_len], &ldig).?).?; try std.testing.expectEqual(logstore.FrameKind.zlib, log.kindOf(last_rid)); const got = (try log.content(gpa, last_rid)).?; defer gpa.free(got); try std.testing.expectEqualStrings(content.items, got); } test "sync §7.2/§7.4/§3.3: a dangling reverse-delta cfile persists as a verbatim .delta frame that re-serves byte-identically" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // Fossil's clone stream: a reverse delta (the OLDER revision stored as a // delta of the NEWER one) arrives BEFORE its basis, so it is dangling. The // client retains the §4.1 container through the pending queue and, when the // basis lands, persists it as a compact `.delta` — not a full re-compression. const newer = "artifact NEWER: a long shared body kept verbatim so the reverse delta reconstructing the older revision stays a compact copy op"; const older = "artifact NEWER: a long shared body kept verbatim so the reverse delta reconstructing the older revision stays a compact copy op minus tail"; var nn: [64]u8 = undefined; var on: [64]u8 = undefined; const newer_name = zit.artifact.artifactName(.sha3_256, newer, &nn); const older_name = zit.artifact.artifactName(.sha3_256, older, &on); const newer_container = try zit.container.encode(gpa, newer, .default); defer gpa.free(newer_container); const raw = try zit.delta.create(gpa, newer, older); // OLDER = delta ∘ NEWER defer gpa.free(raw); const delta_container = try zit.container.encode(gpa, raw, .default); defer gpa.free(delta_container); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var client = zit.sync.Client{ .operation = .clone, .state = .clone_round0 }; defer client.deinit(gpa); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.appendSlice(gpa, "clone_seqno 0\npush 1111111111111111 aa87ea41b0bb05ba528f7a3bdba700d7e23d6318\n"); // Reverse (dangling) order: the delta cfile precedes its basis cfile. const dh = try std.fmt.allocPrint(gpa, "cfile {s} {s} {d} {d}\n", .{ older_name, newer_name, older.len, delta_container.len }); defer gpa.free(dh); try body.appendSlice(gpa, dh); try body.appendSlice(gpa, delta_container); try body.append(gpa, '\n'); const bh = try std.fmt.allocPrint(gpa, "cfile {s} {d} {d}\n", .{ newer_name, newer.len, newer_container.len }); defer gpa.free(bh); try body.appendSlice(gpa, bh); try body.appendSlice(gpa, newer_container); try body.append(gpa, '\n'); _ = try client.consumeReply(gpa, ms.store(), body.items); const written = try persistStore(gpa, io, tmp.dir, &ms); try std.testing.expectEqual(@as(usize, 2), written); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); // §3.3 — the older revision persisted as a `.delta`, not a full re-compression, // and reconstructs the original bytes (delta ∘ basis). var od: [digest_len]u8 = undefined; const older_rid = log.lookup(decodeWireName(older_name, &od).?).?; try std.testing.expectEqual(logstore.FrameKind.delta, log.kindOf(older_rid)); const got = (try log.content(gpa, older_rid)).?; defer gpa.free(got); try std.testing.expectEqualStrings(older, got); // §7.2 — a clone-of-clone re-serve re-emits the SAME delta container byte-for-byte. var serve_ms = repostate.MemStore.init(gpa); defer serve_ms.deinit(); try loadContentStore(gpa, &log, &serve_ms); const srid = serve_ms.store().lookup(older_name).?; const wf = serve_ms.store().wire(srid).?; try std.testing.expect(wf.delta != null); try std.testing.expectEqualSlices(u8, delta_container, wf.container); try std.testing.expectEqualStrings(newer_name, wf.delta.?.src_hash); } test "sync §7.2/§3.3: persistStore stores a whole reverse-delta chain, ordering every basis frame before the delta that points at it" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // A reverse-delta chain in fossil's storage shape: revision i is stored as a // delta of the NEWER revision i+1, the newest stored full. The store rids run // oldest..newest (put order), so EVERY delta's basis sits at a HIGHER rid than // the delta — the worst case for physical ordering. persist's worklist must // still write each basis frame before its delta so `delta_of` points backward; // naive rid-order would degrade all but the oldest link to full form. const links = 6; var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); var bodies: [links][]u8 = undefined; var made: usize = 0; defer for (bodies[0..made]) |b| gpa.free(b); var names: [links][64]u8 = undefined; var nlen: [links]usize = undefined; for (0..links) |i| { bodies[i] = try std.fmt.allocPrint(gpa, "chain body: one long shared prefix kept across every revision for compact reverse deltas -- revision {d}", .{i}); made += 1; nlen[i] = zit.artifact.artifactName(.sha3_256, bodies[i], &names[i]).len; } // put oldest..newest (store rids 1..links); older i is a reverse delta of newer i+1. for (0..links) |i| { const rid = try s.put(names[i][0..nlen[i]], bodies[i]); if (i == links - 1) { const c = try zit.container.encode(gpa, bodies[i], .default); defer gpa.free(c); try s.retain(rid, .{ .full = c }); } else { const dr = try zit.delta.create(gpa, bodies[i + 1], bodies[i]); // older from newer defer gpa.free(dr); const c = try zit.container.encode(gpa, dr, .default); defer gpa.free(c); try s.retain(rid, .{ .delta = .{ .src = names[i + 1][0..nlen[i + 1]], .container = c, .target_size = bodies[i].len } }); } } _ = try persistStore(gpa, io, tmp.dir, &ms); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); // The newest link is full; every older link is a `.delta` whose basis frame is // physically earlier (a LOWER log rid = a lower offset), and all reconstruct. for (0..links) |i| { var d: [digest_len]u8 = undefined; const rid = log.lookup(decodeWireName(names[i][0..nlen[i]], &d).?).?; const rgot = (try log.content(gpa, rid)).?; defer gpa.free(rgot); try std.testing.expectEqualStrings(bodies[i], rgot); if (i == links - 1) { try std.testing.expectEqual(logstore.FrameKind.zlib, log.kindOf(rid)); } else { try std.testing.expectEqual(logstore.FrameKind.delta, log.kindOf(rid)); var bd: [digest_len]u8 = undefined; const brid = log.lookup(decodeWireName(names[i + 1][0..nlen[i + 1]], &bd).?).?; try std.testing.expect(brid < rid); // basis frame precedes the reverse-delta } } } test "sync §7.7: persistStore verifies a retained delta's reconstruction and refuses a lying delta container, writing no frame" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp.dir); // A retained delta whose stored plaintext does NOT hash to its wire name (a // clone skips the transfer-layer pre-storage check, §7.7 r6, so a lying // delta reaches the persist commit hook). The §7.7 recompute runs BEFORE the // wire-form branch, so a delta-form entry is rejected exactly like a full one. var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); const content = "content that the wire name does not actually name, delivered as a delta\n"; const lying_name = "0" ** 64; // not SHA3-256(content) const some_basis = "1" ** 64; // an absent basis; verify fails before it is even consulted const raw = try zit.delta.create(gpa, "a plausible basis blob", content); defer gpa.free(raw); const dc = try zit.container.encode(gpa, raw, .default); defer gpa.free(dc); const rid = try s.put(lying_name, content); try s.retain(rid, .{ .delta = .{ .src = some_basis, .container = dc, .target_size = content.len } }); try std.testing.expectError(error.Corrupt, persistStore(gpa, io, tmp.dir, &ms)); var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); var digest_buf: [digest_len]u8 = undefined; try std.testing.expect(log.lookup(decodeWireName(lying_name, &digest_buf).?) == null); // no frame landed } fn persistReverseChainNoLeak(allocator: std.mem.Allocator) !void { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try d4SeedEmptyRepo(allocator, io, tmp.dir); // A small reverse-delta chain built on the (failing) allocator so injected // OOM is exercised in both the store build and the persist worklist. var ms = repostate.MemStore.init(allocator); defer ms.deinit(); const s = ms.store(); const links = 4; var bodies: [links][]u8 = undefined; var made: usize = 0; defer for (bodies[0..made]) |b| allocator.free(b); var names: [links][64]u8 = undefined; var nlen: [links]usize = undefined; for (0..links) |i| { bodies[i] = try std.fmt.allocPrint(allocator, "reverse chain oom: one shared body across revisions -- revision {d}", .{i}); made += 1; nlen[i] = zit.artifact.artifactName(.sha3_256, bodies[i], &names[i]).len; } for (0..links) |i| { const rid = try s.put(names[i][0..nlen[i]], bodies[i]); if (i == links - 1) { const c = try zit.container.encode(allocator, bodies[i], .default); defer allocator.free(c); try s.retain(rid, .{ .full = c }); } else { const dr = try zit.delta.create(allocator, bodies[i + 1], bodies[i]); defer allocator.free(dr); const c = try zit.container.encode(allocator, dr, .default); defer allocator.free(c); try s.retain(rid, .{ .delta = .{ .src = names[i + 1][0..nlen[i + 1]], .container = c, .target_size = bodies[i].len } }); } } _ = try persistStore(allocator, io, tmp.dir, &ms); } test "sync §7.2/§3.3: persistStore drains the reverse-delta worklist without leaking under allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, persistReverseChainNoLeak, .{}); } /// Drives `persistArtifact` over a FORWARD delta chain (each link a delta of the /// immediately older, already-persisted lower-rid link) in rid order, so a test /// owns the shared basis memo and can read its hit counter. Every basis precedes /// its delta, so `allow_defer=false` never defers and no worklist is needed — /// mirrors `persistStore`'s open/commit around a plain rid-order drive. fn persistForwardChain( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, ms: *repostate.MemStore, cache: *logstore.PlaintextCache, ) !void { var file = try dir.openFile(io, repo_filename, .{ .mode = .read_write }); defer file.close(io); const st = try file.stat(io); var fs = storage.FileStorage.init(io, file, st.size); var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0); defer log.deinit(gpa); const store = ms.store(); const n = store.count(); var rid: repostate.Rid = 1; while (rid <= n) : (rid += 1) { switch (try persistArtifact(gpa, ms, &log, rid, false, cache)) { .persisted => {}, .deferred => unreachable, // forward chain: no basis is ever absent } } try log.commit(); } test "sync §7.2/§7.4: the basis memo reuses each basis (hits) and reconstructs a byte-identical store to the uncached path" { const gpa = std.testing.allocator; const io = std.testing.io; const links = 8; // A forward delta chain: link 0 is full, every later link is a delta that // reconstructs its body FROM the older body (its basis a lower, already- // persisted rid) — the case where the memo turns each basis reconstruction // into a lookup for the delta built on it. Store owns its copies (retain // dupes), but the bodies/names back the reconstructed-content assertions. var ms = repostate.MemStore.init(gpa); defer ms.deinit(); const s = ms.store(); var bodies: [links][]u8 = undefined; var made: usize = 0; defer for (bodies[0..made]) |b| gpa.free(b); var names: [links][64]u8 = undefined; var nlen: [links]usize = undefined; for (0..links) |i| { bodies[i] = try std.fmt.allocPrint(gpa, "forward chain shared prefix across every revision -- revision {d}", .{i}); made += 1; nlen[i] = zit.artifact.artifactName(.sha3_256, bodies[i], &names[i]).len; } for (0..links) |i| { const rid = try s.put(names[i][0..nlen[i]], bodies[i]); if (i == 0) { const c = try zit.container.encode(gpa, bodies[i], .default); defer gpa.free(c); try s.retain(rid, .{ .full = c }); } else { const dr = try zit.delta.create(gpa, bodies[i - 1], bodies[i]); // newer body FROM its older basis defer gpa.free(dr); const c = try zit.container.encode(gpa, dr, .default); defer gpa.free(c); try s.retain(rid, .{ .delta = .{ .src = names[i - 1][0..nlen[i - 1]], .container = c, .target_size = bodies[i].len } }); } } // (A) a normal memo; (B) a zero-budget memo that can hold nothing — the exact // uncached reconstruction. Same store, two freshly-seeded repos. var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp_a.dir); var cache_a = logstore.PlaintextCache.init(); defer cache_a.deinit(gpa); try persistForwardChain(gpa, io, tmp_a.dir, &ms, &cache_a); var tmp_b = std.testing.tmpDir(.{}); defer tmp_b.cleanup(); try d4SeedEmptyRepo(gpa, io, tmp_b.dir); var cache_b = logstore.PlaintextCache.init(); cache_b.max_bytes = 0; // never memoises: forces a fresh reconstruction each time defer cache_b.deinit(gpa); try persistForwardChain(gpa, io, tmp_b.dir, &ms, &cache_b); // The memo reused every basis exactly once (one hit per delta link); the // zero-budget run reused nothing — the two ways the same bytes get built. try std.testing.expectEqual(@as(u64, links - 1), cache_a.hits); try std.testing.expectEqual(@as(u64, 0), cache_b.hits); // Byte-identical store: the memo changes only the work, never the bytes. const bytes_a = try tmp_a.dir.readFileAlloc(io, repo_filename, gpa, .limited(1 << 24)); defer gpa.free(bytes_a); const bytes_b = try tmp_b.dir.readFileAlloc(io, repo_filename, gpa, .limited(1 << 24)); defer gpa.free(bytes_b); try std.testing.expectEqualSlices(u8, bytes_b, bytes_a); // Every link reconstructs to its exact body (§7.7 chain correctness). var fs: storage.FileStorage = undefined; var log = try d4OpenLog(gpa, io, tmp_a.dir, &fs); defer fs.file.close(io); defer log.deinit(gpa); for (0..links) |i| { var d: [digest_len]u8 = undefined; const rid = log.lookup(decodeWireName(names[i][0..nlen[i]], &d).?).?; const got = (try log.content(gpa, rid)).?; defer gpa.free(got); try std.testing.expectEqualStrings(bodies[i], got); } } test "sync §9.1: repoProjectCode returns the stored code, else the zero placeholder" { const gpa = std.testing.allocator; const io = std.testing.io; const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); const seed = struct { fn withCode(g: std.mem.Allocator, i: std.Io, dir: std.Io.Dir, hk: u8, code: []const u8) !void { var f = try dir.createFile(i, repo_filename, .{ .read = true, .exclusive = true }); defer f.close(i); var fs = storage.FileStorage.init(i, f, 0); var log = try logstore.LogStore.open(g, fs.storageFile(), hk); defer log.deinit(g); try log.putConfig(.{ .hash_policy = hk, .default_branch = "trunk", .project_id = "", .project_code = code }); try log.commit(); } }; // (a) a repo whose config stores a real 40-hex project code: it is returned // verbatim — the code the pull/push card announces (§9.1), never the zeros // fossil would refuse. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const code = "a1b2c3d4" ** 5; // 40 hex chars try seed.withCode(gpa, io, tmp.dir, hash_kind, code); var buf: [zit.sync.max_projectcode_len]u8 = undefined; const got = try repoProjectCode(gpa, io, tmp.dir, &buf); try std.testing.expectEqualStrings(code, got); try std.testing.expect(!std.mem.eql(u8, got, sync_projectcode)); // (b) a repo predating the stored code (empty project_code): the placeholder // is the honest fallback (no adopted identity to announce yet). var tmp2 = std.testing.tmpDir(.{}); defer tmp2.cleanup(); try seed.withCode(gpa, io, tmp2.dir, hash_kind, ""); var buf2: [zit.sync.max_projectcode_len]u8 = undefined; const got2 = try repoProjectCode(gpa, io, tmp2.dir, &buf2); try std.testing.expectEqualStrings(sync_projectcode, got2); } test "sync §9.1: the pull request card carries the adopted project code, not the placeholder" { const gpa = std.testing.allocator; // The pull client, constructed exactly as `pullRun` does, with a stored code. const code = "a1b2c3d4" ** 5; // a real (non-zero) 40-hex project code var client = zit.sync.Client{ .state = zit.sync.initialState(.pull), .operation = .pull, .self_servercode = sync_servercode, .project_code = code, .version = .{ .version = sync_client_version }, }; defer client.deinit(gpa); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); var buf: std.ArrayListUnmanaged(u8) = .empty; defer buf.deinit(gpa); const request = try client.buildRequest(gpa, ms.store(), &buf); // §9.1 ABNF `pull-card = "pull" SP servercode SP projectcode`: the adopted // code MUST be the card's PROJECTCODE (a real peer refuses a mismatch). const expected = "pull " ++ sync_servercode ++ " " ++ ("a1b2c3d4" ** 5) ++ "\n"; try std.testing.expect(std.mem.indexOf(u8, request, expected) != null); // And the all-zero placeholder is NOT the project code the card announces. const rejected = "pull " ++ sync_servercode ++ " " ++ sync_projectcode ++ "\n"; try std.testing.expect(std.mem.indexOf(u8, request, rejected) == null); } test "serve §3.8: a per-request store-load on one serve handle sees a later append (no restart)" { const gpa = std.testing.allocator; const io = std.testing.io; const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const append = struct { fn artifactFrame(g: std.mem.Allocator, i: std.Io, dir: std.Io.Dir, hk: u8, digest: []const u8, bytes: []const u8) !void { var f = dir.openFile(i, repo_filename, .{ .mode = .read_write }) catch try dir.createFile(i, repo_filename, .{ .read = true }); defer f.close(i); const st = try f.stat(i); var fs = storage.FileStorage.init(i, f, st.size); var log = try logstore.LogStore.open(g, fs.storageFile(), hk); defer log.deinit(g); _ = try log.put(digest, bytes); try log.commit(); } }; // Seed the repo holding exactly one content artifact (a 32-byte raw digest). const digest_a = "a" ** digest_len; try append.artifactFrame(gpa, io, tmp.dir, hash_kind, digest_a, "content a\n"); // Open ONE read handle — the handle `serve` holds for its whole lifetime. var handle = try tmp.dir.openFile(io, repo_filename, .{}); defer handle.close(io); // First per-request read: one content artifact. var ms1 = repostate.MemStore.init(gpa); defer ms1.deinit(); try readServedStore(gpa, io, handle, &ms1); const n1 = contentCount(ms1.store()); try std.testing.expectEqual(@as(usize, 1), n1); // A SEPARATE writer appends a second artifact while the handle stays open — // exactly a `zit commit` running alongside a live `zit serve`. const digest_b = "b" ** digest_len; try append.artifactFrame(gpa, io, tmp.dir, hash_kind, digest_b, "content b\n"); // Second per-request read on the SAME handle sees the new artifact: proof the // server re-reads the current on-disk log per request (§3.8). A startup // snapshot would still report n1 — no restart is needed here. var ms2 = repostate.MemStore.init(gpa); defer ms2.deinit(); try readServedStore(gpa, io, handle, &ms2); try std.testing.expectEqual(n1 + 1, contentCount(ms2.store())); } test "cli D2 B-init-12: 'fetch' teaches bidirectional sync, exit 2 stderr-only" { try expectReflex("fetch", "zit sync"); } test "cli D2 B-init-12: 'stash' states it is not implemented, exit 2 stderr-only" { try expectReflex("stash", "not implemented"); } test "cli commit B-commit-5: 'zit commit' with no -m is a usage error (real verb, dispatched)" { // Regression: `commit` is now a real handler, not a planned stub. `run()`'s // commit arm raises the missing-message usage error before any I/O, so this // is driven in-process — exit 2, stderr the B-commit-5 message, empty stdout. const r = RunCapture.run_verb("commit"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n", r.err, ); } test "cli clone: 'zit clone' with no arguments is a usage error (real verb, dispatched)" { // `clone` is now a real handler, not a planned stub. `run()`'s clone arm // parses the empty argument list and raises the missing-URL usage error // before any I/O — exit 2, the diagnostic on stderr, empty stdout. const r = RunCapture.run_verb("clone"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expectEqualStrings( "zit clone: no repository URL given\ntry 'zit clone --help'\n", r.err, ); } test "cli D4 B-init-12: a near-miss verb suggests the closest command, exit 2 stderr-only" { const r = RunCapture.run_verb("ini"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'ini'") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'init'?") != null); } test "cli D4 B-init-12: a typo of a planned verb suggests the canonical command" { // `comit` is neither reflex nor exactly planned; the generic path should // suggest the planned canonical `commit` (planned verbs join the near-miss // set), so the typo does not dead-end. const r = RunCapture.run_verb("comit"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'comit'") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'commit'?") != null); } test "cli D4 B-init-12: a typo of 'update' suggests it (every dispatched verb is suggestable)" { // `update` is a real dispatched verb, so a near-miss must land on it. Guards // against the suggester's candidate list drifting from the dispatched set. const r = RunCapture.run_verb("updat"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'updat'") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'update'?") != null); } test "cli D4 B-init-12: a distant unknown lists commands with no misleading guess" { const r = RunCapture.run_verb("florble"); try std.testing.expectEqual(cli.Exit.usage, r.code); try std.testing.expectEqualStrings("", r.out); try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'florble'") != null); try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean") == null); try std.testing.expect(std.mem.indexOf(u8, r.err, "commands:") != null); } // --- commit error-mapper unit tests (B-commit-15; pure switches) ----------- /// 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() }; } 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\n", commitMapCapture(commitParentCorrupt, .{}).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"}).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, ); } test "diff: diffLogFail maps the contract messages and exit 1, exhaustively over all 3 logstore.OpenError variants" { const Case = struct { err: logstore.OpenError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "zit diff: out of memory\n" }, .{ .err = error.Corrupt, .want = "zit diff: cannot read repository: repository is corrupt\n" }, .{ .err = error.Storage, .want = "zit diff: cannot read repository: input/output error\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, diffLogFail(s, c.err)); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "diff: diffComparisonFail maps the contract messages and exit 1, exhaustively over all 3 comparison-error variants" { const Case = struct { err: error{ OutOfMemory, Storage, Corrupt }, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "zit diff: out of memory\n" }, .{ .err = error.Corrupt, .want = "zit diff: cannot compare: repository is corrupt\n" }, .{ .err = error.Storage, .want = "zit diff: cannot compare working tree: input/output error\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, diffComparisonFail(s, c.err)); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "update: ApplyError maps to the contract messages and exit 1, exhaustively over all 23 variants" { const target_id = "1" ** 40; const Case = struct { err: update.ApplyError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "zit update: out of memory\n" }, .{ .err = error.InvalidCheckinId, .want = "zit update: invalid check-in ID '" ++ target_id ++ "' (expected 40 or 64 lowercase hexadecimal digits)\n", }, .{ .err = error.CheckinNotFound, .want = "zit update: check-in '" ++ target_id ++ "' not found\n" }, .{ .err = error.CheckinContentMissing, .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content is missing\n", }, .{ .err = error.CheckinNameMismatch, .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content does not match its name\n", }, .{ .err = error.TargetNotManifest, .want = "zit update: check-in '" ++ target_id ++ "' is not a valid manifest\n" }, .{ .err = error.BaselineNotFound, .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline is not found\n", }, .{ .err = error.BaselineContentMissing, .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content is missing\n", }, .{ .err = error.BaselineNameMismatch, .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content does not match its name\n", }, .{ .err = error.BaselineNotManifest, .want = "zit update: check-in '" ++ target_id ++ "' baseline is not a valid manifest\n", }, .{ .err = error.BaselineIsDelta, .want = "zit update: check-in '" ++ target_id ++ "' baseline is not fully resolvable\n", }, .{ .err = error.StateRecordInvalid, .want = "zit update: cannot read repository: repository is corrupt\n" }, .{ .err = error.InvalidPath, .want = "zit update: check-in '" ++ target_id ++ "' names an invalid file path\n" }, .{ .err = error.ReservedPath, .want = "zit update: check-in '" ++ target_id ++ "' names a reserved file path\n" }, .{ .err = error.SymlinkUnsupported, .want = "zit update: check-in '" ++ target_id ++ "' contains an unsupported symlink entry\n", }, .{ .err = error.CurrentStateInvalid, .want = "zit update: current working-tree state is invalid\n" }, .{ .err = error.WorktreeNotClean, .want = "zit update: working tree is not clean\n" }, .{ .err = error.Corrupt, .want = "zit update: cannot read repository: repository is corrupt\n" }, .{ .err = error.UnmanagedCollision, .want = "zit update: an untracked file or directory is in the way\n" }, .{ .err = error.Storage, .want = "zit update: cannot update working tree: input/output error\n" }, .{ .err = error.TransactionUnavailable, .want = "zit update: cannot create a transaction directory\n" }, .{ .err = error.RollbackFailed, .want = "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n", }, .{ .err = error.CleanupFailed, .want = "zit update: update committed, but the transaction directory could not be removed\n", }, }; for (cases) |c| { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, updateApplyFail(s, target_id, c.err)); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "diff: output write failure is exit 1" { var out_buf: [0]u8 = .{}; var out = Io.Writer.fixed(&out_buf); var err_buf: [64]u8 = undefined; var err = Io.Writer.fixed(&err_buf); const streams: cli.Streams = .{ .out = &out, .err = &err }; try std.testing.expectEqual(cli.Exit.failure, writeDiffOutput(streams, "M a\n")); try std.testing.expectEqualStrings("zit diff: cannot write output\n", err.buffered()); } test "tracked file size limit admits its boundary" { const limit: u64 = max_tracked_file_bytes; try std.testing.expect(trackedFileSizeAllowed(limit - 1)); try std.testing.expect(trackedFileSizeAllowed(limit)); try std.testing.expect(!trackedFileSizeAllowed(limit + 1)); } // --- renderBranchList unit tests (repo-state §6.1/§6.3/§6.4/§9) ------------- // The derived §6.3 state (which rid represents a branch, its closed status, the // grouping) is proven in repostate_test.zig; these tests fix only the CLI // presentation over hand-built BranchEntry values transcribed from §6.3. const BranchEntry = repostate.RepoState.BranchEntry; /// Renders `entries`/`current` into a fixed buffer and returns the written /// slice — the shared body of the presentation tests. fn renderBranchesToBuf(buf: []u8, entries: []const BranchEntry, current: ?[]const u8) []const u8 { var w = Io.Writer.fixed(buf); renderBranchList(&w, entries, current); return w.buffered(); } test "renderBranchList §6.3: a representative-closed branch renders \"(closed)\"" { // §6.3 divergence: a forked branch whose greatest-mtime representative is // closed lists as closed even while an older leaf stays open. The closed // flag is the representative-row property; render appends the marker. const entries = [_]BranchEntry{ .{ .name = "trunk", .tip = 1, .mtime = 3, .count = 2, .closed = true }, }; var buf: [64]u8 = undefined; try std.testing.expectEqualStrings(" trunk (closed)\n", renderBranchesToBuf(&buf, &entries, null)); } test "renderBranchList §6.3: multiple branches render name-ascending, current marked once" { // `branches` returns entries name-ascending; render preserves that order and // marks exactly the current branch, indenting the rest. const entries = [_]BranchEntry{ .{ .name = "feature", .tip = 2, .mtime = 5, .count = 1, .closed = false }, .{ .name = "trunk", .tip = 1, .mtime = 9, .count = 3, .closed = false }, }; var buf: [64]u8 = undefined; try std.testing.expectEqualStrings( "* feature\n trunk\n", renderBranchesToBuf(&buf, &entries, "feature"), ); } test "renderBranchList §9: a forked branch (two leaves) renders as one line" { // §9: a branch with more than one leaf is forked but is still a single §6.3 // entry (count > 1), so it lists on exactly one line. const entries = [_]BranchEntry{ .{ .name = "trunk", .tip = 7, .mtime = 4, .count = 2, .closed = false }, }; var buf: [64]u8 = undefined; const out = renderBranchesToBuf(&buf, &entries, "trunk"); try std.testing.expectEqualStrings("* trunk\n", out); try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\n")); } test "renderBranchList §6.4: a null current-branch marks no row" { // Negative space: with no resolvable current branch, no line carries "*". const entries = [_]BranchEntry{ .{ .name = "feature", .tip = 2, .mtime = 5, .count = 1, .closed = false }, .{ .name = "trunk", .tip = 1, .mtime = 9, .count = 1, .closed = false }, }; var buf: [64]u8 = undefined; const out = renderBranchesToBuf(&buf, &entries, null); try std.testing.expectEqualStrings(" feature\n trunk\n", out); try std.testing.expect(std.mem.indexOfScalar(u8, out, '*') == null); } test "renderBranchList §6.1: the configured main-branch name marks its row (not a literal)" { // The current-branch name is whatever branchOf returned through the config // main-branch — here "mainline", not "trunk". Render marks that row, so a // non-trunk default is honored end to end. const entries = [_]BranchEntry{ .{ .name = "mainline", .tip = 1, .mtime = 9, .count = 1, .closed = false }, .{ .name = "trunk", .tip = 2, .mtime = 3, .count = 1, .closed = false }, }; var buf: [64]u8 = undefined; try std.testing.expectEqualStrings( "* mainline\n trunk\n", renderBranchesToBuf(&buf, &entries, "mainline"), ); } // =========================================================================== // `zit pack` — repack/GC tests. Storage on-disk layout is unspecified by the // RFCs, so the oracle is the store's OWN semantics: every artifact's content // and name survive a repack byte-for-byte, the set is preserved (no drop), the // §7.7 name↔content invariant is re-checked before every write, the packed // bytes are a deterministic function of the source content (idempotent), and a // crash before the atomic rename leaves the original store untouched. Stores // are built as FULL frames (the migration shape pack exists to compact), // through the real `LogStore`/`artifact` — never seeded from fossil. // =========================================================================== /// Store `content` as a FULL raw frame and return its 64-hex artifact name. fn packSeedBlob(log: *logstore.LogStore, content: []const u8) ![64]u8 { var digest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); _ = try log.put(&digest, content); var name: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, content, &name); return name; } /// Serialize and store (as a FULL frame) a one-file check-in manifest snapshotting /// `file_name` → `blob_name`, optionally descended from `parent_name`. Writes the /// manifest's own 64-hex name into `name_out`. Root check-ins carry a propagating /// `branch=trunk` T card; children carry the parent P card and inherit trunk. fn packSeedCheckin( gpa: std.mem.Allocator, log: *logstore.LogStore, ts: []const u8, parent_name: ?[]const u8, file_name: []const u8, blob_name: []const u8, name_out: *[64]u8, ) !void { var pbuf: [1]zit.artifact.PCard = undefined; var p_slice: []const zit.artifact.PCard = &.{}; if (parent_name) |pn| { pbuf[0] = .{ .parents = pn, .count = 1 }; p_slice = pbuf[0..1]; } const fcards = [_]zit.artifact.FCard{ .{ .filename = file_name, .hash = blob_name, .perm = null, .old_name = null, .mode = .regular }, }; const root_tags = [_]zit.artifact.TCard{ .{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" }, }; var m: zit.artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(gpa), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = "c" }, .d = .{ .timestamp = ts }, .e = null, .g = null, .h = null, .i = null, .k = null, .l = null, .n = null, .r = null, .u = .{ .login = "u" }, .z = null, .w = null, .f = &fcards, .j = &.{}, .m = &.{}, .p = p_slice, .q = &.{}, .t = if (parent_name == null) &root_tags else &.{}, }; defer m.deinit(); const bytes = try zit.artifact.create(&m, gpa); defer gpa.free(bytes); var digest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{}); _ = try log.put(&digest, bytes); _ = zit.artifact.artifactName(.sha3_256, bytes, name_out); } /// Seed a `depth`-deep linear check-in chain into `log` (index 0 = the deepest /// ancestor, index depth-1 = the tip), filling `names_out` with each check-in's /// 64-hex name. The deepest check-in's sole P parent is a check-in NOT stored /// here — a phantom. Used to witness `walkLog`'s windowed early exit: the /// phantom line is emitted only if the walk descends past the deepest ancestor. fn logSeedPhantomChain( gpa: std.mem.Allocator, log: *logstore.LogStore, comptime depth: usize, names_out: *[depth][64]u8, ) !void { try log.putConfig(.{ .hash_policy = 2, .default_branch = "trunk", .project_id = "pid", .project_code = "code-abc" }); // A valid 64-hex name for content deliberately absent from the store. var phantom: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, "unstored-parent", &phantom); var i: usize = 0; while (i < depth) : (i += 1) { const v = try packEvolvingBlob(gpa, @intCast(i + 1)); defer gpa.free(v); const b = try packSeedBlob(log, v); const parent: []const u8 = if (i == 0) &phantom else &names_out[i - 1]; var ts_buf: [32]u8 = undefined; const ts = try std.fmt.bufPrint(&ts_buf, "2020-01-{d:0>2}T00:00:00", .{i + 1}); try packSeedCheckin(gpa, log, ts, parent, "f.txt", &b, &names_out[i]); } try log.commit(); } /// Count the lines of `text` that begin with `prefix` (test-only line tally). fn logCountLinePrefix(text: []const u8, prefix: []const u8) usize { var n: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |line| { if (std.mem.startsWith(u8, line, prefix)) n += 1; } return n; } /// A large, near-identical evolving file: 128 identical lines plus a per-version /// trailer, so successive versions delta to a fraction of the full blob (the /// change is materially < 90%). Caller frees. fn packEvolvingBlob(gpa: std.mem.Allocator, version: u8) ![]u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(gpa); var i: usize = 0; while (i < 128) : (i += 1) try buf.print(gpa, "line {d} of the tracked file\n", .{i}); try buf.print(gpa, "trailer version {d}\n", .{version}); return buf.toOwnedSlice(gpa); } /// Build a full-form 3-check-in chain over an evolving file into `log`, plus a /// live config frame; returns the tip check-in's 64-hex name. Every blob and /// manifest lands as a FULL frame — pack's re-delta target. fn packSeedFullStore(gpa: std.mem.Allocator, log: *logstore.LogStore, tip_out: *[64]u8) !void { try log.putConfig(.{ .hash_policy = 2, .default_branch = "trunk", .project_id = "pid", .project_code = "code-abc" }); var root_name: [64]u8 = undefined; var mid_name: [64]u8 = undefined; const v1 = try packEvolvingBlob(gpa, 1); defer gpa.free(v1); const b1 = try packSeedBlob(log, v1); try packSeedCheckin(gpa, log, "2020-01-01T00:00:00", null, "f.txt", &b1, &root_name); const v2 = try packEvolvingBlob(gpa, 2); defer gpa.free(v2); const b2 = try packSeedBlob(log, v2); try packSeedCheckin(gpa, log, "2020-01-02T00:00:00", &root_name, "f.txt", &b2, &mid_name); const v3 = try packEvolvingBlob(gpa, 3); defer gpa.free(v3); const b3 = try packSeedBlob(log, v3); try packSeedCheckin(gpa, log, "2020-01-03T00:00:00", &mid_name, "f.txt", &b3, tip_out); try log.commit(); } /// Store `content` under its sha3-256 name, delta-encoded against `base` when /// one is given (the evolving blobs delta to a fraction, so the frame lands as a /// `.delta`), and return its rid. Models a fossil-clone store that RETAINED /// compact wire deltas rather than full frames. fn packSeedDeltaBlob(log: *logstore.LogStore, content: []const u8, base: ?logstore.Rid) !logstore.Rid { var digest: [digest_len]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{}); return log.putArtifact(&digest, content, base); } /// Build a clone-shaped store whose successive blobs are RETAINED as `.delta` /// frames (the compact wire form), under DISTINCT filenames so pack's /// same-path-blob lineage hint finds NO base for them — exactly the case where /// the old pack reconstructed every delta to a full frame and inflated the /// store. `.zit` therefore holds real delta frames whose bases pack cannot /// re-derive, so a correct pack must preserve them verbatim. Returns the tip /// check-in's 64-hex name. fn packSeedDeltaStore(gpa: std.mem.Allocator, log: *logstore.LogStore, tip_out: *[64]u8) !void { try log.putConfig(.{ .hash_policy = 2, .default_branch = "trunk", .project_id = "pid", .project_code = "code-abc" }); var root_name: [64]u8 = undefined; var mid_name: [64]u8 = undefined; const v1 = try packEvolvingBlob(gpa, 1); defer gpa.free(v1); const r1 = try packSeedDeltaBlob(log, v1, null); // first version: full frame (base of the chain) var b1: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, v1, &b1); try packSeedCheckin(gpa, log, "2020-01-01T00:00:00", null, "f1.txt", &b1, &root_name); const v2 = try packEvolvingBlob(gpa, 2); defer gpa.free(v2); const r2 = try packSeedDeltaBlob(log, v2, r1); // delta of v1 var b2: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, v2, &b2); try packSeedCheckin(gpa, log, "2020-01-02T00:00:00", &root_name, "f2.txt", &b2, &mid_name); const v3 = try packEvolvingBlob(gpa, 3); defer gpa.free(v3); _ = try packSeedDeltaBlob(log, v3, r2); // delta of v2 var b3: [64]u8 = undefined; _ = zit.artifact.artifactName(.sha3_256, v3, &b3); try packSeedCheckin(gpa, log, "2020-01-03T00:00:00", &mid_name, "f3.txt", &b3, tip_out); try log.commit(); } /// Count the `.delta` frames among a store's persisted artifacts. fn packDeltaFrameCount(gpa: std.mem.Allocator, log: *logstore.LogStore) !usize { const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); var n: usize = 0; for (rids) |r| { if (log.kindOf(r) == .delta) n += 1; } return n; } /// Open a fresh, empty (header + config-less) writable `.zit` in `dir`. fn packOpenWritable(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, fs_out: *storage.FileStorage) !logstore.LogStore { const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false)); const f = try dir.createFile(io, repo_filename, .{ .read = true, .exclusive = true }); fs_out.* = storage.FileStorage.init(io, f, 0); return logstore.LogStore.open(gpa, fs_out.storageFile(), hash_kind); } /// Reopen an existing `.zit` in `dir` read-only. fn packOpenReadOnly(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, fs_out: *storage.FileStorage) !logstore.LogStore { const f = try dir.openFile(io, repo_filename, .{}); const st = try f.stat(io); fs_out.* = storage.FileStorage.init(io, f, st.size); return logstore.LogStore.openReadOnly(gpa, fs_out.storageFile()); } /// Assert that every artifact in `src` reconstructs byte-identically in `dst` /// and the two artifact SETS are equal (no drop, no addition). fn packExpectContentEqual(gpa: std.mem.Allocator, src: *logstore.LogStore, dst: *logstore.LogStore) !void { const src_rids = try src.ridsInPersistedOrder(gpa); defer gpa.free(src_rids); const dst_rids = try dst.ridsInPersistedOrder(gpa); defer gpa.free(dst_rids); try std.testing.expectEqual(src_rids.len, dst_rids.len); for (src_rids) |sr| { const digest = src.hashOf(sr); const dr = dst.lookup(digest) orelse return error.PackedArtifactMissing; const a = (try src.content(gpa, sr)).?; defer gpa.free(a); const b = (try dst.content(gpa, dr)).?; defer gpa.free(b); try std.testing.expectEqualSlices(u8, a, b); } } test "log walk: `zit log CHECKIN` prints every reachable ancestor in P-card DFS order then the missing parent as a phantom" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const depth = 6; var names: [depth][64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try logSeedPhantomChain(gpa, &wlog, depth, &names); } var sfs: storage.FileStorage = undefined; var log = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer log.deinit(gpa); const tip: []const u8 = &names[depth - 1]; // The unbounded walk prints all `depth` check-ins tip->root in P-card DFS // order, then the deepest ancestor's phantom parent — exactly once each. var buf: [4096]u8 = undefined; var w = Io.Writer.fixed(&buf); try walkLog(gpa, &log, tip, &w, null); const out = w.buffered(); try std.testing.expectEqual(@as(usize, depth), logCountLinePrefix(out, "checkin ")); try std.testing.expectEqual(@as(usize, 1), logCountLinePrefix(out, "phantom ")); var ebuf: [4096]u8 = undefined; var stream = Io.Writer.fixed(&ebuf); var d: usize = depth; while (d > 0) : (d -= 1) stream.print("checkin {s}\n", .{names[d - 1][0..]}) catch unreachable; const want_checkins = stream.buffered(); try std.testing.expectEqualStrings(want_checkins, out[0..want_checkins.len]); } test "pack: the packed store's artifact set + every reconstructed content byte-equal the original (config preserved)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedFullStore(gpa, &wlog, &tip); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var dfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer dfile.close(io); var dfs = storage.FileStorage.init(io, dfile, 0); var dst = try logstore.LogStore.open(gpa, dfs.storageFile(), src.hash_kind); defer dst.deinit(gpa); const stats = try packLog(gpa, &src, &dst); // No drop: the packed content-frame count equals the source's, and every // artifact reconstructs byte-for-byte. try std.testing.expectEqual(src.entries.items.len, stats.artifacts); try packExpectContentEqual(gpa, &src, &dst); // The live config survives as exactly one frame with identical fields. var soc = (try src.readConfig(gpa)).?; defer soc.deinit(gpa); var doc = (try dst.readConfig(gpa)).?; defer doc.deinit(gpa); try std.testing.expectEqual(soc.config.hash_policy, doc.config.hash_policy); try std.testing.expectEqualStrings(soc.config.default_branch, doc.config.default_branch); try std.testing.expectEqualStrings(soc.config.project_id, doc.config.project_id); try std.testing.expectEqualStrings(soc.config.project_code, doc.config.project_code); } test "pack re-deltas full-form frames (delta count rises, .zit shrinks) with byte-identical content" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedFullStore(gpa, &wlog, &tip); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); // The source is all full-form: not one delta frame. { const rids = try src.ridsInPersistedOrder(gpa); defer gpa.free(rids); for (rids) |r| try std.testing.expect(src.kindOf(r) != .delta); } var dfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer dfile.close(io); var dfs = storage.FileStorage.init(io, dfile, 0); var dst = try logstore.LogStore.open(gpa, dfs.storageFile(), src.hash_kind); defer dst.deinit(gpa); const stats = try packLog(gpa, &src, &dst); // The two child blobs (v2 against v1, v3 against v2) re-delta against the // same path's parent blob, so the delta count rises and the store shrinks — // with content preserved exactly. try std.testing.expect(stats.deltas >= 2); try std.testing.expect(stats.bytes_after < stats.bytes_before); try packExpectContentEqual(gpa, &src, &dst); } test "pack preserves wire deltas — a store with delta frames packs to <= source size (regression for the 8.65x inflation bug)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedDeltaStore(gpa, &wlog, &tip); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); // The source genuinely holds retained wire deltas (the compact form pack // must not discard) whose bases pack's lineage hint cannot re-derive. const src_deltas = try packDeltaFrameCount(gpa, &src); try std.testing.expect(src_deltas >= 2); var dfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer dfile.close(io); var dfs = storage.FileStorage.init(io, dfile, 0); var dst = try logstore.LogStore.open(gpa, dfs.storageFile(), src.hash_kind); defer dst.deinit(gpa); const stats = try packLog(gpa, &src, &dst); // MONOTONIC: the packed store is never larger than the source. The old pack // reconstructed each delta to a full zlib frame and inflated the store; a // correct pack preserves the compact deltas verbatim. try std.testing.expect(stats.bytes_after <= stats.bytes_before); // Every source delta survives AS a delta frame — none reconstructed to full // (the optional re-delta may add further deltas, so the total only grows). { const rids = try src.ridsInPersistedOrder(gpa); defer gpa.free(rids); for (rids) |sr| { if (src.kindOf(sr) != .delta) continue; const dr = dst.lookup(src.hashOf(sr)).?; try std.testing.expectEqual(logstore.FrameKind.delta, dst.kindOf(dr)); } } try std.testing.expect(try packDeltaFrameCount(gpa, &dst) >= src_deltas); // No drop, every reconstruction byte-exact. try packExpectContentEqual(gpa, &src, &dst); } test "pack copies a delta frame verbatim re-linked to its base's new rid, content byte-identical" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedDeltaStore(gpa, &wlog, &tip); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var dfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer dfile.close(io); var dfs = storage.FileStorage.init(io, dfile, 0); var dst = try logstore.LogStore.open(gpa, dfs.storageFile(), src.hash_kind); defer dst.deinit(gpa); _ = try packLog(gpa, &src, &dst); // For every delta frame in the source, the packed store holds it as a delta // whose stored container is byte-identical (verbatim re-serve) and whose base // names the SAME artifact (re-linked to the base's new rid). Content matches. const rids = try src.ridsInPersistedOrder(gpa); defer gpa.free(rids); var checked: usize = 0; for (rids) |sr| { if (src.kindOf(sr) != .delta) continue; const digest = src.hashOf(sr); const dr = dst.lookup(digest).?; try std.testing.expectEqual(logstore.FrameKind.delta, dst.kindOf(dr)); var sw = (try src.wire(gpa, sr)).?; defer sw.deinit(gpa); var dw = (try dst.wire(gpa, dr)).?; defer dw.deinit(gpa); // Verbatim container bytes and the same base hash (re-link preserves the // logical base while its rid/offset differs in the packed generation). try std.testing.expectEqualSlices(u8, sw.delta.container, dw.delta.container); try std.testing.expectEqualSlices(u8, sw.delta.baseHash(), dw.delta.baseHash()); const a = (try src.content(gpa, sr)).?; defer gpa.free(a); const b = (try dst.content(gpa, dr)).?; defer gpa.free(b); try std.testing.expectEqualSlices(u8, a, b); checked += 1; } try std.testing.expect(checked >= 2); } test "pack §7.7: packLog aborts on a source frame whose content does not name it (no packed frame written)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // A frame keyed by a name that is NOT sha3-256 of its bytes: `put` stores // bytes verbatim under any key, so this models a corrupt/lying source frame // that survives `openReadOnly` (its CRC + embedded-hash label are intact). const lying_name = [_]u8{0xab} ** digest_len; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); _ = try wlog.put(&lying_name, "content these bytes do not name"); try wlog.commit(); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var dfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer dfile.close(io); var dfs = storage.FileStorage.init(io, dfile, 0); var dst = try logstore.LogStore.open(gpa, dfs.storageFile(), src.hash_kind); defer dst.deinit(gpa); try std.testing.expectError(error.Corrupt, packLog(gpa, &src, &dst)); try std.testing.expect(dst.lookup(&lying_name) == null); // no frame landed on the abort } test "pack is idempotent: pack∘pack is byte-identical (0 additional re-deltas)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedFullStore(gpa, &wlog, &tip); } // First pack: source (full) → L1. { var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var f1 = try tmp.dir.createFile(io, "pack1", .{ .read = true, .truncate = true }); defer f1.close(io); var f1fs = storage.FileStorage.init(io, f1, 0); var dst = try logstore.LogStore.open(gpa, f1fs.storageFile(), src.hash_kind); defer dst.deinit(gpa); _ = try packLog(gpa, &src, &dst); } // Second pack: L1 → L2, byte-for-byte equal, and no further re-deltaing. var l1fs: storage.FileStorage = undefined; var l1 = try packOpenReadOnly2(gpa, io, tmp.dir, "pack1", &l1fs); defer l1fs.file.close(io); defer l1.deinit(gpa); var f2 = try tmp.dir.createFile(io, "pack2", .{ .read = true, .truncate = true }); defer f2.close(io); var f2fs = storage.FileStorage.init(io, f2, 0); var l2 = try logstore.LogStore.open(gpa, f2fs.storageFile(), l1.hash_kind); defer l2.deinit(gpa); const stats2 = try packLog(gpa, &l1, &l2); try std.testing.expectEqual(l1.size(), l2.size()); const a = try tmp.dir.readFileAlloc(io, "pack1", gpa, .limited(1 << 24)); defer gpa.free(a); const b = try tmp.dir.readFileAlloc(io, "pack2", gpa, .limited(1 << 24)); defer gpa.free(b); try std.testing.expectEqualSlices(u8, a, b); // byte-identical: pack∘pack == pack // An already-packed store still reports its deltas (they were re-created // identically), and packing it does not shrink further. try std.testing.expectEqual(l1.size(), stats2.bytes_after); } /// Reopen a named log file in `dir` read-only (a sibling to `packOpenReadOnly` /// that takes an arbitrary name, for the multi-generation idempotence test). fn packOpenReadOnly2(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8, fs_out: *storage.FileStorage) !logstore.LogStore { const f = try dir.openFile(io, name, .{}); const st = try f.stat(io); fs_out.* = storage.FileStorage.init(io, f, st.size); return logstore.LogStore.openReadOnly(gpa, fs_out.storageFile()); } test "pack: packLog never writes the source (a crash before the rename leaves the original byte-identical)" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedFullStore(gpa, &wlog, &tip); } const before = try tmp.dir.readFileAlloc(io, repo_filename, gpa, .limited(1 << 24)); defer gpa.free(before); // Pack into a separate temp (the new generation). packLog only ever reads // the source; the atomic swap in `runPack` is the sole mutation of `.zit`. { var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var f = try tmp.dir.createFile(io, pack_tmp_filename, .{ .read = true, .truncate = true }); defer f.close(io); var ffs = storage.FileStorage.init(io, f, 0); var dst = try logstore.LogStore.open(gpa, ffs.storageFile(), src.hash_kind); defer dst.deinit(gpa); _ = try packLog(gpa, &src, &dst); } // No rename happened (the "crash"): the original `.zit` is byte-for-byte // unchanged and still opens. const after = try tmp.dir.readFileAlloc(io, repo_filename, gpa, .limited(1 << 24)); defer gpa.free(after); try std.testing.expectEqualSlices(u8, before, after); } /// The allocation-failure probe: pack a prebuilt source `.zit` into a fresh temp /// destination, using the failing allocator throughout. Every OOM point must /// unwind cleanly (no leak) and a fully-funded run must succeed. fn packOomProbe(alloc: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void { var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(alloc, io, dir, &sfs); defer sfs.file.close(io); defer src.deinit(alloc); var f = try dir.createFile(io, "oomdst", .{ .read = true, .truncate = true }); defer f.close(io); var ffs = storage.FileStorage.init(io, f, 0); var dst = try logstore.LogStore.open(alloc, ffs.storageFile(), src.hash_kind); defer dst.deinit(alloc); _ = try packLog(alloc, &src, &dst); } test "pack: checkAllAllocationFailures on packLog (every OOM path unwinds cleanly)" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(std.testing.allocator, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(std.testing.allocator); try packSeedFullStore(std.testing.allocator, &wlog, &tip); } try std.testing.checkAllAllocationFailures(std.testing.allocator, packOomProbe, .{ io, tmp.dir }); } test "pack∘derive == from-scratch derive: the packed store derives the identical repo view" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var tip: [64]u8 = undefined; { var wfs: storage.FileStorage = undefined; var wlog = try packOpenWritable(gpa, io, tmp.dir, &wfs); defer wfs.file.close(io); defer wlog.deinit(gpa); try packSeedFullStore(gpa, &wlog, &tip); } var sfs: storage.FileStorage = undefined; var src = try packOpenReadOnly(gpa, io, tmp.dir, &sfs); defer sfs.file.close(io); defer src.deinit(gpa); var src_view: zit.repoview.RepoView = undefined; try src_view.load(gpa, &src); defer src_view.deinit(gpa); var pfile = try tmp.dir.createFile(io, "packed", .{ .read = true, .truncate = true }); defer pfile.close(io); var pfs = storage.FileStorage.init(io, pfile, 0); var dst = try logstore.LogStore.open(gpa, pfs.storageFile(), src.hash_kind); _ = try packLog(gpa, &src, &dst); dst.deinit(gpa); var rfs: storage.FileStorage = undefined; var packed_log = try packOpenReadOnly2(gpa, io, tmp.dir, "packed", &rfs); defer rfs.file.close(io); defer packed_log.deinit(gpa); var packed_view: zit.repoview.RepoView = undefined; try packed_view.load(gpa, &packed_log); defer packed_view.deinit(gpa); // Same derived view: main branch, artifact count, and the leaf set + order // (§7.5) are identical — pack changed only the storage layout. try std.testing.expectEqualStrings(src_view.main_branch, packed_view.main_branch); try std.testing.expectEqual(src_view.store.store().count(), packed_view.store.store().count()); var la = src_view.state.leaves(); var lb = packed_view.state.leaves(); while (la.next()) |ra| { const rb = lb.next() orelse return error.LeafCountMismatch; try std.testing.expectEqualStrings(src_view.hashOf(ra), packed_view.hashOf(rb)); } try std.testing.expect(lb.next() == null); } test "renderPackStats: 'packed' when the store shrank, 'already packed' otherwise" { var buf: [128]u8 = undefined; { var w: std.Io.Writer = .fixed(&buf); renderPackStats(&w, .{ .artifacts = 1695, .deltas = 1373, .bytes_before = 9_680_000, .bytes_after = 920_000 }); try std.testing.expectEqualStrings( "packed: 1695 artifacts, 1373 re-deltaed, 9680000 -> 920000 bytes\n", w.buffered(), ); } { var w: std.Io.Writer = .fixed(&buf); renderPackStats(&w, .{ .artifacts = 3, .deltas = 2, .bytes_before = 4096, .bytes_after = 4096 }); try std.testing.expectEqualStrings( "already packed: 3 artifacts, 2 re-deltaed, 4096 -> 4096 bytes\n", w.buffered(), ); } }