//! 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 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));
}
/// The top-level subcommand set. `unknown` is the exhaustive catch-all so the
/// dispatch switch below is total.
const Verb = enum { init, add, rm, status, diff, commit, log, update, version, help, unknown };
fn verbOf(name: []const u8) Verb {
if (std.mem.eql(u8, name, "init")) return .init;
if (std.mem.eql(u8, name, "add")) return .add;
if (std.mem.eql(u8, name, "rm")) return .rm;
if (std.mem.eql(u8, name, "status")) return .status;
if (std.mem.eql(u8, name, "diff")) return .diff;
if (std.mem.eql(u8, name, "commit")) return .commit;
if (std.mem.eql(u8, name, "log")) return .log;
if (std.mem.eql(u8, name, "update")) return .update;
if (std.mem.eql(u8, name, "version")) return .version;
if (std.mem.eql(u8, name, "help") or std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) return .help;
return .unknown;
}
/// Maps a verb to its `--help` block so `zit help <verb>` teaches exactly what
/// `zit <verb> --help` prints (DX D4). Returns `null` for verbs that carry no
/// dedicated help (`version`, `help`) and for any unknown topic; the caller then
/// falls back to `top_usage`. Every real working verb with a help block is
/// listed, so no verb's topic silently drops.
fn helpFor(name: []const u8) ?[]const u8 {
if (std.mem.eql(u8, name, "init")) return init_help;
if (std.mem.eql(u8, name, "add")) return add_help;
if (std.mem.eql(u8, name, "rm")) return rm_help;
if (std.mem.eql(u8, name, "status")) return status_help;
if (std.mem.eql(u8, name, "diff")) return diff_help;
if (std.mem.eql(u8, name, "commit")) return commit_help;
if (std.mem.eql(u8, name, "log")) return log_help;
if (std.mem.eql(u8, name, "update")) return update_help;
return null;
}
/// 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 (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),
.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 the read-only working-tree verbs.
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 the read-only working-tree verbs.
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: anyerror) cli.Exit {
switch (err) {
error.OutOfMemory => return diffOom(s),
error.Corrupt => s.err.writeAll("zit diff: cannot read repository: repository is corrupt\n") catch {},
else => 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: anyerror) cli.Exit {
switch (err) {
error.OutOfMemory => return diffOom(s),
error.Corrupt => s.err.writeAll("zit diff: cannot compare: repository is corrupt\n") catch {},
else => 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 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: anyerror) cli.Exit {
switch (err) {
error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {},
error.CheckinNotFound => s.err.print("zit update: check-in '{s}' not found\n", .{target_id}) catch {},
error.TargetNotManifest => s.err.print("zit update: check-in '{s}' is not a valid manifest\n", .{target_id}) catch {},
error.WorktreeNotClean => s.err.writeAll("zit update: working tree is not clean\n") catch {},
error.Corrupt => s.err.writeAll("zit update: cannot read repository: repository is corrupt\n") catch {},
else => s.err.print("zit update: cannot update working tree: {s}\n", .{reasonOf(err)}) 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;
}
/// 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;
return .{ .card = .{ .filename = filename, .hash = hash, .perm = null, .old_name = null, .mode = .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 commitCheckoutFail(streams, 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 commitManagedFail(streams, 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;
}
/// Renders a B-commit-15 checkout-read failure. Exhaustive over the closed
/// `checkout.CheckoutError` set — a new variant fails to compile.
fn commitCheckoutFail(s: cli.Streams, err: checkout.CheckoutError) cli.Exit {
switch (err) {
error.Corrupt => s.err.writeAll(
"zit commit: cannot read checkout state: .zit-checkout is corrupt\n",
) catch {},
error.Storage => s.err.writeAll(
"zit commit: cannot read checkout state: input/output error\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 managed-set read failure. Message shapes match add/
/// status (B-add-17) for cross-verb consistency; exhaustive over `ManagedError`.
fn commitManagedFail(s: cli.Streams, err: managed.ManagedError) cli.Exit {
switch (err) {
error.Corrupt => s.err.writeAll(
"zit commit: cannot read tracked files: .zit-managed is corrupt\n",
) catch {},
error.Storage => s.err.writeAll(
"zit commit: cannot read tracked files: input/output error\n",
) catch {},
error.TooLong => s.err.writeAll(
"zit commit: cannot read tracked files: a tracked path is too long\n",
) catch {},
error.TooMany => s.err.writeAll(
"zit commit: cannot read tracked files: too many tracked files\n",
) catch {},
error.OutOfMemory => s.err.writeAll("zit commit: out of memory\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
\\ 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 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 inline unit tests into `zig build test`.
_ = @import("cli.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);
}
/// Asserts a planned-but-unimplemented `verb` renders the exact promise line:
/// exit 2, stdout empty, stderr `zit: '<verb>' is planned but not implemented
/// yet.\n` and nothing else. A planned verb is a real zit command that simply
/// isn't built yet (e.g. `commit`, which `init --help` points the user at), so
/// unlike a git-reflex verb it is never told it "is not a zit command".
fn expectPlanned(verb: []const u8) !void {
const r = RunCapture.run_verb(verb);
try std.testing.expectEqual(cli.Exit.usage, r.code);
try std.testing.expectEqualStrings("", r.out);
var buf: [128]u8 = undefined;
const expected = try std.fmt.bufPrint(&buf, "zit: '{s}' is planned but not implemented yet.\n", .{verb});
try std.testing.expectEqualStrings(expected, r.err);
}
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 D2 B-init-12: 'push' teaches bidirectional sync / no force-push, exit 2 stderr-only" {
try expectReflex("push", "force-push does not exist");
}
test "cli D2 B-init-12: 'pull' teaches bidirectional sync, exit 2 stderr-only" {
try expectReflex("pull", "zit sync");
}
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 D2 B-init-12: 'clone' is a planned verb, exit 2 stderr-only" {
try expectPlanned("clone");
}
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 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(commitCheckoutFail, .{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(commitManagedFail, .{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: 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));
}