//! 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");
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, version, help, unknown };
fn verbOf(name: []const u8) Verb {
if (std.mem.eql(u8, name, "init")) return .init;
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),
.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 ".";
const cwd = std.process.currentPathAlloc(io, arena) catch |e|
return renderStorage(streams, dir_arg, reasonOf(e));
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 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
\\ 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)
\\
;
test {
// Pull the CLI harness's inline unit tests into `zig build test`.
_ = @import("cli.zig");
}
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);
}