//! 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");
// Storage and update are private modules. Build wiring injects the same
// instances into every consumer so their values never cross module identities.
const logstore = @import("zit_logstore");
const storage = @import("zit_storage");
const update = @import("zit_update");
const checkout = @import("zit_checkout");
const repoview = @import("repoview.zig");
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 two ambient effects `commit` needs, 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.
const amb: cli.Ambient = .{
.now_epoch = commitEpoch(io, init.environ_map),
.user_env = init.environ_map.get("USER") orelse init.environ_map.get("LOGNAME"),
};
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,
.status = status_help,
.diff = diff_help,
.commit = commit_help,
.log = log_help,
.update = update_help,
.leaves = leaves_help,
.branch = branch_help,
.tag = tag_help,
.info = info_help,
.serve = serve_help,
.clone = clone_help,
.pull = pull_help,
.push = push_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),
.status => return runStatus(gpa, io, arena, args[2..], streams),
.diff => return runDiff(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),
.leaves => return runLeaves(gpa, io, arena, args[2..], streams),
.branch => return runBranch(gpa, io, arena, args[2..], streams),
.tag => return runTag(gpa, io, arena, args[2..], streams),
.info => return runInfo(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),
.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));
};
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" }) 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 one of the three repo-internal control files, which are
/// never tracked (B-add-7): `.zit`, `.zit-checkout`, `.zit-managed`.
fn isControlFile(key: []const u8) bool {
return std.mem.eql(u8, key, repo_filename) or
std.mem.eql(u8, key, checkout.filename) or
std.mem.eql(u8, key, managed.filename);
}
/// 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;
}
/// The repository file's fixed name (logstore §E: a copied `.zit` is a
/// complete repository).
const repo_filename: []const u8 = ".zit";
// --- 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 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,
failure: Failure = .unresolved,
fn deinit(self: *DiffBaselineResolver) void {
if (self.manifest) |*m| m.deinit();
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;
}
const manifest = artifact.parse(self.gpa, bytes) catch |err| {
self.failure = if (err == error.OutOfMemory) .oom else .corrupt;
return null;
};
self.manifest = manifest;
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);
var current = artifact.parse(gpa, current_bytes) catch |err| switch (err) {
error.OutOfMemory => return diffOom(streams),
else => return diffCurrentCorrupt(streams),
};
defer current.deinit();
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;
}
fn walkLog(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
root_id: []const u8,
out: *Io.Writer,
) 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;
out.print("phantom {s}\n", .{id}) catch {};
continue;
};
const bytes = (try log.content(gpa, rid)) orelse {
if (is_root) return error.MissingRoot;
out.print("phantom {s}\n", .{id}) catch {};
continue;
};
defer gpa.free(bytes);
if (!artifact.verify(id, bytes)) return error.Corrupt;
var manifest = artifact.parse(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return if (is_root) error.InvalidRoot else error.InvalidTarget,
};
defer manifest.deinit();
if (manifest.kind != .manifest)
return if (is_root) error.InvalidRoot else error.InvalidTarget;
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..]);
}
}
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);
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) 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;
}
/// `zit leaves [--all]` — list the open leaves of the check-in DAG (repo-state
/// §7.1/§7.4/§7.5). Discovers the worktree, opens the repository read-only,
/// builds the crosslinked repo-view, 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, never a
/// resolver's precedence choice. An unborn repository has no leaves: empty
/// stdout, exit 0.
/// A loaded read-only repository view plus the checkout root it was discovered
/// through. After `openRepoView`, `view` is self-contained (RepoState owns its
/// duped bytes), so the log/file are already closed; `root_dir` is retained
/// only for the checkout read. Caller-pinned: `view.state` borrows `&view.store`,
/// so a filled `LoadedRepo` must not be moved.
const LoadedRepo = struct {
root_dir: std.Io.Dir,
view: repoview.RepoView,
};
/// Discover the checkout root, open `.zit` read-only, and crosslink it into
/// `repo.view` for the read-only verb family (leaves, branch). One place owns
/// the open sequence and its per-verb diagnostics (not-a-repo, cannot-read,
/// OOM, and the closed corrupt/storage load map) so every read-only verb reads
/// identical text. On `.ok`, `repo` is filled and the caller must
/// `repo.view.deinit(gpa)` then close `repo.root_dir`; on any non-`.ok` the
/// diagnostic is already on stderr and `repo` is left untouched (the root dir
/// is closed here).
fn openRepoView(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
verb: []const u8,
repo: *LoadedRepo,
) cli.Exit {
const root = (cli.discoverRoot(arena, io) catch
return stateOom(streams, verb)) 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, verb);
};
defer file.close(io);
const log_st = file.stat(io) catch {
root_dir.close(io);
return repoReadFail(streams, verb);
};
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.print("zit {s}: out of memory\n", .{verb}) catch {},
else => streams.err.print("zit {s}: cannot read repository: {s}\n", .{ verb, reasonOf(e) }) catch {},
}
return .failure;
};
defer log.deinit(gpa);
repo.view.load(gpa, &log) catch |e| {
root_dir.close(io);
return viewLoadFail(streams, verb, e);
};
repo.root_dir = root_dir;
return .ok;
}
/// The current checkout's crosslinked rid, or null when there is nothing to
/// mark: no `.zit-checkout`, an unborn checkout, a version this view never
/// interned, or an interned-but-eventless phantom. The `mtimeOf != null` guard
/// is the postcondition every caller leans on — a returned rid always bears an
/// event, so `branchOf`'s `events.contains` precondition holds by construction.
/// Without it a stale or hand-edited record naming a phantom parent (interned
/// eventless when its child's P card is crosslinked) could resolve to an
/// eventless rid and trip that assertion.
fn currentCheckoutRid(io: std.Io, repo: *LoadedRepo) ?repostate.Rid {
var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const ck = (checkout.read(io, repo.root_dir, &ck_buf) catch return null) orelse return null;
if (ck.version.len == 0) return null;
const rid = repo.view.lookup(ck.version) orelse return null;
if (repo.view.state.mtimeOf(rid) == null) return null;
return rid;
}
/// 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 repo-view load failure to the verb's diagnostic + exit 1. Closed over
/// `RepoView.LoadError` so the CLI switch stays exhaustive; general over the
/// verb name so every read-only verb shares one map.
fn viewLoadFail(s: cli.Streams, verb: []const u8, err: repoview.RepoView.LoadError) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
error.Storage => 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;
}
/// `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);
const server = zit.sync.Server{
.version = serve_mod.server_version,
.credential = null,
.self_servercode = serve_mod.server_servercode,
.project_code = serve_mod.server_projectcode,
};
var ctx = ServeCtx{ .io = io, .root_dir = root_dir, .log = &log, .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,
log: *logstore.LogStore,
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 loadContentStore(gpa, ctx.log, &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);
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;
}
}
}
// ---------------------------------------------------------------------------
// `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 rounds = 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"),
};
streams.out.print("cloned {d} artifacts into {s} in {d} rounds\n", .{ written, parsed.dir, rounds }) catch {};
return .ok;
}
/// 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.
fn cloneRun(
gpa: std.mem.Allocator,
tr: *transport.Transport,
ms: *repostate.MemStore,
) sync_driver.RunError(*transport.Transport)!usize {
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);
return stats.rounds;
}
/// 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 };
/// 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();
var rid: repostate.Rid = 1;
while (rid <= n) : (rid += 1) {
// Skip any residual phantom (a hash the peer announced but never sent);
// only content-bearing artifacts are durable.
const content = store.content(rid) orelse continue;
var digest_buf: [digest_len]u8 = undefined;
const digest = decodeWireName(store.hashOf(rid), &digest_buf) orelse return error.Corrupt;
_ = log.put(digest, content) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => return error.Storage,
};
written += 1;
}
log.commit() catch return error.Storage;
return written;
}
/// 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;
}
// ---------------------------------------------------------------------------
// `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). The URL is required — no saved
/// remote is persisted yet, so a pull always names its peer. 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),
.no_url => return cli.usageError(streams, "pull", .no_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);
var tr = transport.Transport.open(io, gpa, parsed.url) catch |e|
return syncOpenFail(streams, "pull", parsed.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());
const rounds = pullRun(gpa, &tr, &ms) 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,
) sync_driver.RunError(*transport.Transport)!usize {
var client = zit.sync.Client{
.state = zit.sync.initialState(.pull),
.operation = .pull,
.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);
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,
};
}
/// 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). The URL is required — no saved
/// remote is persisted yet, so a push always names its peer. Anonymous only.
fn runPush(
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.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),
.no_url => return cli.usageError(streams, "push", .no_url, ""),
};
if (parsed.help) {
streams.out.writeAll(push_help) catch {};
return .ok;
}
// 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);
var tr = transport.Transport.open(io, gpa, parsed.url) catch |e|
return syncOpenFail(streams, "push", parsed.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"),
};
const outcome = pushRun(gpa, &tr, &ms) catch |e|
return syncRunFail(streams, "push", e);
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;
}
/// 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,
) sync_driver.RunError(*transport.Transport)!PushOutcome {
var client = zit.sync.Client{
.state = zit.sync.initialState(.push),
.operation = .push,
.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);
// 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 };
}
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: LoadedRepo = undefined;
const rc = openRepoView(gpa, io, arena, streams, "leaves", &repo);
if (rc != .ok) return rc;
defer repo.view.deinit(gpa);
defer repo.root_dir.close(io);
// 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 = currentCheckoutRid(io, &repo);
return renderLeaves(arena, &repo.view, 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,
view: *repoview.RepoView,
all: bool,
cur: ?repostate.Rid,
streams: cli.Streams,
) cli.Exit {
var rows: std.ArrayList(LeafRow) = .empty;
defer rows.deinit(arena);
const bound = view.state.leaves().rids.len;
var scanned: usize = 0;
var it = view.state.leaves();
while (it.next()) |rid| {
scanned += 1;
std.debug.assert(scanned <= bound);
const open = view.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 = view.state.mtimeOf(rid) orelse unreachable;
rows.append(arena, .{
.rid = rid,
.mtime = mtime,
.hash = view.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(view.state.isLeaf(row.rid));
std.debug.assert(all or row.open);
const mark = if (cur != null and cur.? == row.rid) "* " else " ";
const branch = view.state.branchOf(row.rid, view.main_branch);
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;
}
/// `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 help = switch (cli.parseNoPositionalArgs(verb_args)) {
.ok => |a| a.help,
.unknown_option => |tok| return cli.usageError(streams, "branch", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "branch", .extra_argument, tok),
};
if (help) {
streams.out.writeAll(branch_help) catch {};
return .ok;
}
var repo: LoadedRepo = undefined;
const rc = openRepoView(gpa, io, arena, streams, "branch", &repo);
if (rc != .ok) return rc;
defer repo.view.deinit(gpa);
defer repo.root_dir.close(io);
// §6.3: the branch set is derived by grouping active branch tags — never
// stored, never re-hardcoded here.
const entries = repo.view.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 = currentCheckoutRid(io, &repo);
const current_name: ?[]const u8 =
if (cur) |r| repo.view.state.branchOf(r, repo.view.main_branch) 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-";
/// `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: LoadedRepo = undefined;
const rc = openRepoView(gpa, io, arena, streams, "tag", &repo);
if (rc != .ok) return rc;
defer repo.view.deinit(gpa);
defer repo.root_dir.close(io);
if (parsed.checkin) |name| return runTagCheckin(gpa, &repo.view, name, streams);
// §5.1/§2: the distinct active tag names in use — derived, never stored.
const names = repo.view.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,
view: *repoview.RepoView,
name: []const u8,
streams: cli.Streams,
) cli.Exit {
const rid = view.state.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 = view.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 `openRepoView`.
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: LoadedRepo = undefined;
const rc = openRepoView(gpa, io, arena, streams, "info", &repo);
if (rc != .ok) return rc;
defer repo.view.deinit(gpa);
defer repo.root_dir.close(io);
// §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.view.state.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.view, 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,
view: *repoview.RepoView,
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(view.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", .{view.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", .{view.state.branchOf(rid, view.main_branch)}) 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 (view.state.isOpenLeaf(rid))
"open"
else if (view.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 = view.state.primaryParentOf(rid);
if (primary) |p| streams.out.print("parent: {s}\n", .{view.hashOf(p)}) catch {};
return renderMergeParents(arena, view, rid, primary, streams);
}
/// 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,
view: *repoview.RepoView,
rid: repostate.Rid,
primary: ?repostate.Rid,
streams: cli.Streams,
) cli.Exit {
var merges: std.ArrayList([]const u8) = .empty;
defer merges.deinit(arena);
const bound = view.state.parentCount(rid);
var scanned: usize = 0;
var it = view.state.parentsOf(rid);
while (it.next()) |parent| {
scanned += 1;
std.debug.assert(scanned <= bound);
if (primary != null and parent == primary.?) continue;
merges.append(arena, view.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 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.parseLogArgs(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 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,
) 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);
_ = log.put(&digest, content) 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,
} };
}
/// `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;
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();
// 7. 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)) {
.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)) {
.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),
}
}
}
// 8. Parent F-set (child only): read + parse the parent manifest for
// carry-forward and the no-op verdict (B-commit-9/11). Slice-1 parents are
// always full manifests, so no delta resolution is reached.
var pm: ?artifact.Manifest = null;
defer if (pm) |*m| m.deinit();
var parent_bytes: ?[]u8 = null;
defer if (parent_bytes) |b| gpa.free(b);
var parent_f: []const artifact.FCard = &.{};
if (!is_root) {
const pdigest = hexToDigest(ck.version) orelse return commitParentCorrupt(streams);
const rid = log.lookup(&pdigest) orelse return commitParentCorrupt(streams);
parent_bytes = (log.content(gpa, rid) catch |e| return commitContentFail(streams, e)) orelse
return commitParentCorrupt(streams);
pm = artifact.parse(gpa, parent_bytes.?) catch return commitParentCorrupt(streams);
parent_f = pm.?.f;
}
// 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.
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;
}
// 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);
const root_tags = [_]artifact.TCard{
.{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" },
.{ .sign = .propagate, .name = "sym-trunk", .target = null, .value = null },
};
const child_parents = [_]artifact.PCard{.{ .parents = ck.version, .count = 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 = if (is_root) &root_tags else &.{},
};
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);
_ = log.put(&mdigest, bytes) catch |e| return commitLogFail(streams, e);
log.commit() catch |e| return commitLogFail(streams, 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).
checkout.write(io, root_dir, .{ .version = manifest_name, .branch = branch }) 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. `-q` suppresses it.
if (!parsed.quiet) {
streams.out.print("check-in {s} on {s}\n", .{ manifest_name, 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;
}
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)
\\ status show the working checkout state
\\ diff compare managed files with the current check-in
\\ 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
\\ leaves list the open leaves of the check-in DAG
\\ branch list the repository's branches
\\ tag list the tags in use, or those on a check-in
\\ info resolve a name to a check-in and describe it
\\ 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
\\ version print the version
\\ help show this help
\\
;
const log_help =
\\usage: zit log CHECKIN
\\
\\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.
\\
\\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 leaves_help =
\\usage: zit leaves [--all] [-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)"
\\ -h, --help show this help and exit
\\
;
const branch_help =
\\usage: zit branch [-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:
\\ -h, --help show this help and exit
\\
;
const tag_help =
\\usage: zit tag list [CHECKIN]
\\
\\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
\\
\\ -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.
\\
\\options:
\\ -h, --help show this help and exit
\\
;
const push_help =
\\usage: zit push URL [-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). The push is anonymous: no login.
\\
\\options:
\\ -h, --help show this help and exit
\\
;
const info_help =
\\usage: zit info NAME
\\
\\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:
\\ -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 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 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>] [-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).
\\
\\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)
\\ -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, or
\\ this is not a zit repository
\\ 2 usage error (no message, or an unknown option)
\\
;
test {
// Pull the CLI harness's and repo-view loader's inline unit tests into
// `zig build test`.
_ = @import("cli.zig");
_ = @import("repoview.zig");
_ = @import("transport.zig");
_ = @import("serve.zig");
_ = @import("sync_driver.zig");
_ = @import("sync_driver_test.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 {
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", verb }, 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 D2 B-init-12: 'amend' teaches immutable history, exit 2 stderr-only" {
try expectReflex("amend", "history is immutable");
}
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: 'zit push' with no arguments is a usage error (real verb, dispatched)" {
// `push` is now a real handler, not a git-reflex redirect. `run()`'s push 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("push");
try std.testing.expectEqual(cli.Exit.usage, r.code);
try std.testing.expectEqualStrings("", r.out);
try std.testing.expectEqualStrings(
"zit push: no repository URL given\ntry 'zit push --help'\n",
r.err,
);
}
test "cli pull: 'zit pull' with no arguments is a usage error (real verb, dispatched)" {
// `pull` is now a real handler, not a git-reflex redirect. `run()`'s pull 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("pull");
try std.testing.expectEqual(cli.Exit.usage, r.code);
try std.testing.expectEqualStrings("", r.out);
try std.testing.expectEqualStrings(
"zit pull: no repository URL given\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).
const push_hash = "a1b2c3d4" ** 8; // 64 hex chars = 32-byte digest
const push_body = "pushed artifact bytes\n"; // 22 bytes
const request_plain = "pragma client-version 20000\nfile " ++ push_hash ++ " 22\n" ++ push_body;
const request = try zit.sync.zlibContainerEncode(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; the second
// answer is an idempotent re-push (content is addressed by hash — no dup frame).
for (0..2) |_| {
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);
var ctx = ServeCtx{ .io = io, .root_dir = tmp.dir, .log = &log, .server = &server };
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);
}
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"),
);
}