//! 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 zit = @import("zit");
const cli = @import("cli.zig");
// Storage-layer modules the CLI consumes. They depend on the `zit` module
// (for delta/artifact) rather than living inside it, so — exactly like
// dst.zig and the benchmark — the exe reaches them by relative import, not
// through the public `zit` surface.
const logstore = @import("logstore.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
// The managed-path set is a first-class library module (re-exported by
// root.zig), so it is reached through the public `zit` surface, not by relative
// import. `add` is a pure consumer of it.
const managed = zit.managed;
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;
var out_buf: [4096]u8 = undefined;
var out_writer: Io.File.Writer = .init(.stdout(), io, &out_buf);
var err_buf: [4096]u8 = undefined;
var err_writer: Io.File.Writer = .init(.stderr(), io, &err_buf);
const streams: cli.Streams = .{ .out = &out_writer.interface, .err = &err_writer.interface };
const code = run(gpa, io, arena, args, streams);
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, 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, "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;
}
/// 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,
) 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),
.version => {
streams.out.writeAll("zit 0.0.0 (targets Fossil RFC rev " ++ zit.rfc_revision ++ ")\n") catch {};
return .ok;
},
.help => {
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(
"zit: not a zit repository (no .zit found in this directory or any parent)\n",
) 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;
}
/// 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 ----------------------------------------------------
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
\\ version print the version
\\ help show this help
\\
;
/// 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)
\\
;
test {
// Pull the CLI harness's inline unit tests into `zig build test`.
_ = @import("cli.zig");
}
// --- 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());
}
}
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);
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 D2 B-init-12: 'commit' is a planned verb, not a bare unknown command" {
// Regression: `init --help` points the user at their first `zit commit`,
// so the binary must own that verb as a planned stub, not disown it.
try expectPlanned("commit");
}
test "cli D2 B-init-12: 'status' is a planned verb, exit 2 stderr-only" {
try expectPlanned("status");
}
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);
}