//! zit — CLI-layer verb runner (NOT part of the embeddable `zit` library).
//!
//! Compiled only into the `zit` executable (imported by main.zig); never
//! re-exported from root.zig, so it stays outside the library. It holds the
//! reusable, verb-agnostic mechanism every future verb shares: the uniform
//! exit codes, the stdout/stderr discipline, and a labeled-switch flag
//! scanner. No verb-specific effect logic lives here — that is main.zig's
//! `runInit` (contracts/init.md B-init-1, B-init-10, B-init-12).
const std = @import("std");
const Io = std.Io;
// The working-state record's fixed name (`.zit-checkout`). Imported for the
// authoritative constant used by the shared repo-root probe below; checkout.zig
// itself depends only on std, so this adds no cycle.
const checkout = @import("zit").checkout;
// The artifact-format vocabulary `commit` composes over: the `FCard` value the
// F-set is built from, the §4.5.3 comparator that orders it, the §4.5.2-r1
// admission oracle that guards the order, and the §4.6.4 writer-timestamp
// oracle. Reached through the `zit` module (the same instance main.zig uses),
// so the `FCard` this file produces is byte-for-byte the type `artifact.create`
// consumes — never a second, incompatible copy from a relative import.
const artifact = @import("zit").artifact;
/// Uniform process exit codes across the whole CLI (B-init-12): 0 success,
/// 1 an expected failure, 2 a usage error. Backed by the actual byte handed
/// to `std.process.exit`, so the mapping is defined once and here only.
pub const Exit = enum(u8) { ok = 0, failure = 1, usage = 2 };
/// The two output sinks carried together, so the discipline "data → out,
/// diagnostics → err" is structural rather than a per-call decision
/// (B-init-12). Both writers are owned by the caller and outlive every use.
pub const Streams = struct {
out: *Io.Writer,
err: *Io.Writer,
};
/// The ambient environment/clock values the CLI's runners need, threaded as
/// plain values so each runner stays pure w.r.t. time and environment
/// (design-seams rule: no ambient clock or env read inside a runner). `main`
/// builds it once — the single place these effects are read — and forwards it;
/// a verb that needs none of the fields ignores it. `now_epoch` is whole Unix
/// seconds (UTC, for the D card timestamp, B-commit-7); `user_env` is the
/// `$USER`/`$LOGNAME` fallback author (B-commit-6), or null when neither is
/// set; `push_password` is `$ZIT_PASSWORD` (sync §6.3, a no-persistence
/// password source for `push`), or null when unset.
pub const Ambient = struct {
now_epoch: i64,
user_env: ?[]const u8,
push_password: ?[]const u8 = null,
};
/// The parsed shape of a `zit init` argument list (B-init-1). `directory`
/// null means "the caller's default" (the cwd); the verb, not the parser,
/// owns that default so parsing stays a pure function of its input.
pub const Parsed = struct {
directory: ?[]const u8 = null,
quiet: bool = false,
help: bool = false,
};
/// Result of parsing: a clean `Parsed`, or the exact offending token for one
/// of the two B-init-10 usage errors. A tagged union so the renderer prints
/// the precise token with no out-of-band side channel.
pub const ParseResult = union(enum) {
ok: Parsed,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// The usage errors the shared renderer knows how to phrase. The path-less,
/// check-in-less, and name-less variants carry no token; the others name the
/// offending token.
pub const UsageKind = enum { unknown_option, extra_argument, no_paths, no_checkin, no_name, no_file, no_url, no_dir, bad_subcommand };
// ---------------------------------------------------------------------------
// The one flag scanner every verb shares (Ousterhout: a single deep mechanism,
// not four near-identical per-verb copies). Each verb hands in a `spec` — its
// options and where each records its result — and the scanner drives the common
// two-state grammar (options, then `--`, then positionals). The precedence
// between `--help` and a usage error is the caller's to decide, because it
// differs by verb: init lets an offense stand; add/rm/commit let `--help` win
// (B-add-13/B-rm-13/B-commit-18a). Keeping that policy out of the scanner is
// what lets one scanner serve all four grammars.
// ---------------------------------------------------------------------------
/// One option in a verb's flag grammar. `long` is the mandatory two-dash
/// spelling ("--quiet"); `short` an optional one-dash alias ("-q") or null.
/// `target` is where a match records its result.
const FlagSpec = struct {
long: []const u8,
short: ?[]const u8 = null,
target: Target,
/// A `flag` records a bool set true on sight; a `value` option records the
/// token that immediately follows it.
const Target = union(enum) {
flag: *bool,
value: *?[]const u8,
};
};
/// The one usage error a pure scan can detect, carrying the exact offending
/// token. `unknown_option` covers an unrecognized `-x` and a dangling value
/// option (no following value); `extra_argument` is a positional beyond a
/// grammar's capacity — only a bounded-positional grammar (init) can raise it.
/// The first offense in token order wins.
const Offense = union(enum) {
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Outcome of a scan: how many positionals landed in the caller's buffer and
/// the first offense (or null). Flag and value results are written through the
/// spec's targets, so they are not repeated here.
const ScanResult = struct {
positional_count: usize,
offense: ?Offense = null,
};
/// Returns the target of the spec option `tok` names (by long or short
/// spelling), or null when `tok` matches no option. Bounded by `spec.len`.
fn matchFlag(spec: []const FlagSpec, tok: []const u8) ?FlagSpec.Target {
for (spec) |f| {
if (std.mem.eql(u8, tok, f.long)) return f.target;
if (f.short) |s| if (std.mem.eql(u8, tok, s)) return f.target;
}
return null;
}
/// Appends `tok` to `out[0..count.*]` when room remains, else records the first
/// `extra_argument` offense. The shared positional sink for both scanner states.
fn appendPositional(tok: []const u8, out: [][]const u8, count: *usize, offense: *?Offense) void {
if (count.* < out.len) {
out[count.*] = tok;
count.* += 1;
} else if (offense.* == null) {
offense.* = .{ .extra_argument = tok };
}
}
/// Scans `verb_args` against `spec`, writing recognized flags/values through
/// their targets and positionals into `positionals_out` (up to its length).
/// Pure: no library calls, no I/O, no allocation. A labeled switch drives the
/// two parser states — `.scan` recognizes the `spec` options; `--` switches to
/// `.positional_only`, where every remaining token is a positional (so a file
/// literally named `-q` is reachable). A value option consumes the following
/// token; a dangling one (no following token) is the first offense. Any other
/// `-x` (not a lone `-`) is an unknown option; a lone `-` and every bare word is
/// a positional. The whole list is always scanned (offenses are recorded, never
/// returned early) so a caller can let `--help` win regardless of order.
fn scanFlags(verb_args: []const []const u8, spec: []const FlagSpec, positionals_out: [][]const u8) ScanResult {
const Mode = enum { scan, positional_only };
var count: usize = 0;
var offense: ?Offense = null;
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (matchFlag(spec, tok)) |t| {
switch (t) {
.flag => |b| b.* = true,
.value => |v| {
if (i >= verb_args.len) {
if (offense == null) offense = .{ .unknown_option = tok };
} else {
v.* = verb_args[i];
i += 1;
}
},
}
continue :state .scan;
}
// Any other token that starts with '-' (and is not a lone "-") is an
// unrecognized option; a lone "-" and every bare word is a positional.
if (tok.len > 1 and tok[0] == '-') {
if (offense == null) offense = .{ .unknown_option = tok };
continue :state .scan;
}
appendPositional(tok, positionals_out, &count, &offense);
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
appendPositional(tok, positionals_out, &count, &offense);
continue :state .positional_only;
},
}
std.debug.assert(count <= positionals_out.len);
return .{ .positional_count = count, .offense = offense };
}
/// The positional slice a multi-positional verb (add/rm/commit) scanned, plus
/// the first unknown option (or dangling value option) it saw. `paths` is
/// exactly sized and caller-owned (an empty slice needs no free). These verbs
/// size their buffer to the token count, so a positional never overflows and an
/// `extra_argument` offense cannot arise — only an unknown option can surface.
const Collected = struct {
paths: []const []const u8,
unknown_option: ?[]const u8,
};
/// Scans `verb_args` against `spec` into an exactly-sized, caller-owned
/// positional slice — the one allocating step the three multi-positional verbs
/// share. A scratch buffer sized to the token count (so no positional is ever
/// dropped) is scanned, then shrunk to the positionals actually seen. The caller
/// applies `--help` precedence over the returned `unknown_option`.
fn collectPositionals(gpa: std.mem.Allocator, verb_args: []const []const u8, spec: []const FlagSpec) error{OutOfMemory}!Collected {
const buf = try gpa.alloc([]const u8, verb_args.len);
const scan = scanFlags(verb_args, spec, buf);
const unknown: ?[]const u8 = if (scan.offense) |o| switch (o) {
.unknown_option => |t| t,
.extra_argument => unreachable, // buffer is sized to fit every token
} else null;
if (scan.positional_count == 0) {
gpa.free(buf);
return .{ .paths = &.{}, .unknown_option = unknown };
}
if (scan.positional_count == verb_args.len) {
return .{ .paths = buf, .unknown_option = unknown };
}
const paths = gpa.realloc(buf, scan.positional_count) catch |e| {
gpa.free(buf);
return e;
};
return .{ .paths = paths, .unknown_option = unknown };
}
/// Scans `verb init` arguments into a `ParseResult` (B-init-1) by feeding the
/// init flag spec to the shared `scanFlags`. Pure: no library calls, no I/O, no
/// allocation — the lone positional lands in a stack slot. init accepts a single
/// positional directory; a second is an `extra_argument` error. Unlike the
/// multi-positional verbs, init has no help-wins clause, so an offense stands
/// even when `-h` also appears — the offense is therefore checked first.
pub fn parseInitArgs(verb_args: []const []const u8) ParseResult {
var parsed: Parsed = .{};
var slot: [1][]const u8 = undefined;
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &parsed.help } },
.{ .long = "--quiet", .short = "-q", .target = .{ .flag = &parsed.quiet } },
};
const scan = scanFlags(verb_args, &spec, &slot);
if (scan.offense) |o| return switch (o) {
.unknown_option => |t| .{ .unknown_option = t },
.extra_argument => |t| .{ .extra_argument = t },
};
if (scan.positional_count == 1) parsed.directory = slot[0];
return .{ .ok = parsed };
}
/// The parsed shape of a no-positional verb's argument list. `status` and
/// `diff` each accept only the standard help flag, so sharing this scanner keeps
/// their option/argument precedence byte-identical without adding unreachable
/// fields from `Parsed`.
pub const NoPositionalResult = union(enum) {
ok: struct { help: bool },
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Scans a help-only, no-positional verb's arguments. Pure: no library calls,
/// I/O, or allocation. `-h`/`--help` win over every otherwise-invalid token;
/// `--` switches to positional-only mode, where even option-looking tokens are
/// extra arguments. The first invalid token is retained for the diagnostic.
pub fn parseNoPositionalArgs(verb_args: []const []const u8) NoPositionalResult {
const Mode = enum { scan, positional_only };
var help = false;
var first_err: ?NoPositionalResult = null;
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
continue :state .scan;
}
if (first_err == null) {
first_err = if (tok.len > 1 and tok[0] == '-')
.{ .unknown_option = tok }
else
.{ .extra_argument = tok };
}
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (first_err == null) first_err = .{ .extra_argument = tok };
continue :state .positional_only;
},
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = .{ .help = false } };
}
/// Parsed `zit log CHECKIN` arguments. The one positional is a full check-in
/// artifact ID borrowed from argv; `null` lets the runner render the
/// missing-check-in usage error after help has had its chance to win.
pub const LogArgs = struct {
checkin: ?[]const u8 = null,
help: bool = false,
};
pub const LogResult = union(enum) {
ok: LogArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the one-verb log surface: one full check-in ID and no flags except
/// the standard help request. Pure: no library calls, I/O, or allocation. Help
/// wins over every other token; any `-x` (a bare `--` included) is an unknown
/// option — `log` has no windowing and no positional-only separator — and a
/// second positional is an extra argument. The first offense in token order is
/// retained for the diagnostic.
pub fn parseLogArgs(verb_args: []const []const u8) LogResult {
var parsed: LogArgs = .{};
var first_err: ?LogResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (parsed.checkin == null) {
parsed.checkin = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit update CHECKIN` arguments: one full check-in ID and the help
/// flag. A distinct type from `LogArgs`, though `update` and `log` now share
/// the same one-positional, help-only grammar.
pub const UpdateArgs = struct {
checkin: ?[]const u8 = null,
help: bool = false,
};
pub const UpdateResult = union(enum) {
ok: UpdateArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the one-verb update surface: one full check-in ID and no flags except
/// the standard help request. Pure: no library calls, I/O, or allocation. Help
/// wins over every other token; any `-x` is an unknown option here.
pub fn parseUpdateArgs(verb_args: []const []const u8) UpdateResult {
var parsed: UpdateArgs = .{};
var first_err: ?UpdateResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (parsed.checkin == null) {
parsed.checkin = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit mv OLD NEW` arguments: exactly two positional operands and the
/// help flag. `old`/`new` null means the operand was not supplied — the runner
/// renders the missing-operand usage error after help has had its chance to win
/// (B-mv-14), so parsing stays a pure function of its input.
pub const MvArgs = struct {
old: ?[]const u8 = null,
new: ?[]const u8 = null,
help: bool = false,
};
pub const MvResult = union(enum) {
ok: MvArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the two-positional `zit mv OLD NEW` surface via the shared
/// `scanFlags` scanner (the two-positional variant of the `parseUpdateArgs`
/// template): `-h`/`--help` is the only flag, `--` flips to positional-only (so
/// a file literally named `-x` is reachable), and a THIRD positional overflows
/// the two-slot buffer into an `extra_argument` offense (B-mv-14). Help wins
/// over every otherwise-valid or -invalid token (B-mv-12). Pure: no library
/// calls, I/O, or allocation — the operands land in stack slots. A missing
/// operand is NOT a parse error; the runner maps it to the usage class.
pub fn parseMvArgs(verb_args: []const []const u8) MvResult {
var parsed: MvArgs = .{};
var slots: [2][]const u8 = undefined;
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &parsed.help } },
};
const scan = scanFlags(verb_args, &spec, &slots);
// Help wins over an unknown option or an extra argument (B-mv-12).
if (parsed.help) return .{ .ok = .{ .help = true } };
if (scan.offense) |o| return switch (o) {
.unknown_option => |t| .{ .unknown_option = t },
.extra_argument => |t| .{ .extra_argument = t },
};
if (scan.positional_count >= 1) parsed.old = slots[0];
if (scan.positional_count >= 2) parsed.new = slots[1];
return .{ .ok = parsed };
}
/// Parsed `zit info NAME` arguments. The one positional is a symbolic NAME
/// (repo-state §8) borrowed from argv — deliberately a distinct type from
/// `LogArgs`, whose positional is a full artifact ID; `null` lets the runner
/// render the missing-name usage error after help has had its chance to win.
pub const InfoArgs = struct {
name: ?[]const u8 = null,
/// Bypass the `.zit-cache` memo and force a full-log re-derive.
no_cache: bool = false,
help: bool = false,
};
pub const InfoResult = union(enum) {
ok: InfoArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the `zit info NAME [--no-cache] [-h|--help]` surface: one symbolic
/// name plus the cache-bypass and help flags. Pure: no library calls, I/O, or
/// allocation. Help wins over every other token; a second positional is an
/// extra argument, and any unrecognized option is retained for the diagnostic.
pub fn parseInfoArgs(verb_args: []const []const u8) InfoResult {
var parsed: InfoArgs = .{};
var first_err: ?InfoResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
} else if (std.mem.eql(u8, tok, "--no-cache")) {
parsed.no_cache = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (parsed.name == null) {
parsed.name = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit ls [VERSION]` arguments. The lone positional is an optional
/// symbolic VERSION (repo-state §8); when absent the runner lists the current
/// checkout. Mirrors `InfoArgs` but keeps a distinct type so the runner reads
/// `version` (a check-in selector), not `name`.
pub const LsArgs = struct {
version: ?[]const u8 = null,
/// Bypass the `.zit-cache` memo and force a full-log re-derive.
no_cache: bool = false,
help: bool = false,
};
pub const LsResult = union(enum) {
ok: LsArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses `zit ls [VERSION] [--no-cache] [-h|--help]`: one optional symbolic
/// version plus the cache-bypass and help flags. Pure: no library calls, I/O, or
/// allocation. Help wins over every other token; a second positional is an extra
/// argument, and any unrecognized option is retained for the diagnostic.
pub fn parseLsArgs(verb_args: []const []const u8) LsResult {
var parsed: LsArgs = .{};
var first_err: ?LsResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
} else if (std.mem.eql(u8, tok, "--no-cache")) {
parsed.no_cache = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (parsed.version == null) {
parsed.version = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit cat FILE [VERSION]` arguments. `file` is the required first
/// positional (the pathname to emit); `version` is the optional second
/// positional (a check-in selector — the current checkout when omitted).
/// Mirrors `LsArgs` with the extra required positional; the runner enforces
/// `file != null` as a `.no_file` usage error so parsing stays pure.
pub const CatArgs = struct {
file: ?[]const u8 = null,
version: ?[]const u8 = null,
/// Bypass the `.zit-cache` memo and force a full-log re-derive.
no_cache: bool = false,
help: bool = false,
};
pub const CatResult = union(enum) {
ok: CatArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses `zit cat FILE [VERSION] [--no-cache] [-h|--help]`: the first
/// positional is FILE, the second is an optional VERSION, plus the cache-bypass
/// and help flags. Pure: no library calls, I/O, or allocation. Help wins over
/// every other token; a third positional is an extra argument, and any
/// unrecognized option is retained for the diagnostic. A missing FILE is not an
/// error here — the runner raises `.no_file` — so the grammar stays uniform
/// with `ls`.
pub fn parseCatArgs(verb_args: []const []const u8) CatResult {
var parsed: CatArgs = .{};
var first_err: ?CatResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
} else if (std.mem.eql(u8, tok, "--no-cache")) {
parsed.no_cache = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (parsed.file == null) {
parsed.file = tok;
} else if (parsed.version == null) {
parsed.version = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit leaves` arguments. `all` selects the closed-inclusive listing
/// (repo-state §7.4: closed leaves stay leaf-table members, so the open/closed
/// split is observable); `no_cache` bypasses the `.zit-cache` memo and forces a
/// full-log re-derive (whole-log integrity re-verified); `help` requests the
/// verbatim help block.
pub const LeavesArgs = struct {
all: bool = false,
no_cache: bool = false,
help: bool = false,
};
/// The port `zit serve` binds when `--port` is absent.
pub const default_serve_port: u16 = 8080;
pub const ServeArgs = struct {
port: u16 = default_serve_port,
help: bool = false,
};
pub const ServeResult = union(enum) {
ok: ServeArgs,
unknown_option: []const u8,
extra_argument: []const u8,
/// `--port` carried a value that is not a u16 (or nothing followed it).
bad_port: []const u8,
};
/// Parses `zit serve [--port N] [-h|--help]`. Pure: no library calls, I/O, or
/// allocation. `--port` consumes the following token, parsed as a base-10 u16;
/// `-h`/`--help` win over every otherwise-invalid token; `--` switches to
/// positional-only mode, where any token is an extra argument. The verb takes
/// no positional, so any bare word is an extra argument. The first offense in
/// token order is retained for the diagnostic.
pub fn parseServeArgs(verb_args: []const []const u8) ServeResult {
const Mode = enum { scan, positional_only };
var parsed: ServeArgs = .{};
var help = false;
var first_err: ?ServeResult = null;
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "--port")) {
if (i >= verb_args.len) {
if (first_err == null) first_err = .{ .bad_port = tok };
continue :state .scan;
}
const val = verb_args[i];
i += 1;
if (std.fmt.parseInt(u16, val, 10)) |p| {
parsed.port = p;
} else |_| {
if (first_err == null) first_err = .{ .bad_port = val };
}
continue :state .scan;
}
if (first_err == null) {
first_err = if (tok.len > 1 and tok[0] == '-')
.{ .unknown_option = tok }
else
.{ .extra_argument = tok };
}
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (first_err == null) first_err = .{ .extra_argument = tok };
continue :state .positional_only;
},
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit clone URL DIR` arguments. `url` and `dir` borrow argv tokens;
/// both are required positionals (the runner never sees them empty on `.ok`).
pub const CloneArgs = struct {
url: []const u8 = "",
dir: []const u8 = "",
help: bool = false,
};
pub const CloneResult = union(enum) {
ok: CloneArgs,
unknown_option: []const u8,
extra_argument: []const u8,
/// No repository URL positional was given.
no_url,
/// A URL was given but the target directory positional was not.
no_dir,
};
/// Parses `zit clone URL DIR [-h|--help]`: exactly two positionals — the peer
/// repository URL then the fresh target directory — plus the standard help
/// request. Pure: no library calls, I/O, or allocation. Help wins over every
/// other token (including missing positionals); a third positional is an extra
/// argument; the first offense in token order is retained for the diagnostic.
/// Mirrors the positional grammar of `parseLogArgs`/`parseInfoArgs`.
pub fn parseCloneArgs(verb_args: []const []const u8) CloneResult {
var url: ?[]const u8 = null;
var dir: ?[]const u8 = null;
var help = false;
var first_err: ?CloneResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (url == null) {
url = tok;
} else if (dir == null) {
dir = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
const u = url orelse return .no_url;
const d = dir orelse return .no_dir;
return .{ .ok = .{ .url = u, .dir = d } };
}
/// Parsed `zit pull URL` arguments. `url` borrows an argv token; it is the sole
/// required positional (the runner never sees it empty on `.ok`). A saved remote
/// is not persisted yet, so the URL is always given explicitly.
pub const PullArgs = struct {
url: []const u8 = "",
help: bool = false,
};
pub const PullResult = union(enum) {
ok: PullArgs,
unknown_option: []const u8,
extra_argument: []const u8,
/// No repository URL positional was given.
no_url,
};
/// Parses `zit pull URL [-h|--help]`: exactly one positional — the peer sync
/// endpoint — plus the standard help request. Pure: no library calls, I/O, or
/// allocation. Help wins over every other token (including a missing URL); a
/// second positional is an extra argument; the first offense in token order is
/// retained for the diagnostic. Mirrors `parseCloneArgs` with one positional.
pub fn parsePullArgs(verb_args: []const []const u8) PullResult {
var url: ?[]const u8 = null;
var help = false;
var first_err: ?PullResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (url == null) {
url = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
const u = url orelse return .no_url;
return .{ .ok = .{ .url = u } };
}
/// Parsed `zit push URL [--user U] [--password P]` arguments. `url` borrows an
/// argv token — the sole required positional (never empty on `.ok`). `user` and
/// `password` (sync §6) are null when absent: a null `user` is an anonymous
/// push; a present `user` with a null `password` resolves the secret from the
/// stored credential, the `ZIT_PASSWORD` environment, or a no-echo prompt.
pub const PushArgs = struct {
url: []const u8 = "",
user: ?[]const u8 = null,
password: ?[]const u8 = null,
help: bool = false,
};
pub const PushResult = union(enum) {
ok: PushArgs,
unknown_option: []const u8,
extra_argument: []const u8,
/// No repository URL positional was given.
no_url,
};
/// Parses `zit push URL [--user U] [--password P] [-h|--help]`: exactly one
/// positional — the peer sync endpoint — plus the two value options and the
/// standard help request. Pure: no library calls, I/O, or allocation. Help
/// wins over every other token (including a missing URL); `--user`/`--password`
/// each consume the following token (a dangling one is the offending flag as an
/// unknown option); a second positional is an extra argument; the first offense
/// in token order is retained for the diagnostic. The advance is data-dependent
/// (a value option skips two tokens), so the index loop is `while`, not `for`.
pub fn parsePushArgs(verb_args: []const []const u8) PushResult {
var url: ?[]const u8 = null;
var user: ?[]const u8 = null;
var password: ?[]const u8 = null;
var help = false;
var first_err: ?PushResult = null;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
} else if (std.mem.eql(u8, tok, "--user")) {
if (i + 1 >= verb_args.len) {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else {
i += 1;
user = verb_args[i];
}
} else if (std.mem.eql(u8, tok, "--password")) {
if (i + 1 >= verb_args.len) {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else {
i += 1;
password = verb_args[i];
}
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (url == null) {
url = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
const u = url orelse return .no_url;
return .{ .ok = .{ .url = u, .user = user, .password = password } };
}
/// Parsed `zit sync [URL] [--user U] [--password P]` arguments. Unlike push, the
/// URL positional is OPTIONAL: an empty `url` means "use the saved remote"
/// (sync §4/§6). `user`/`password` are the same §6 credential options push
/// carries — a sync uploads, so it may authenticate. A null `user` is anonymous.
pub const SyncArgs = struct {
url: []const u8 = "",
user: ?[]const u8 = null,
password: ?[]const u8 = null,
help: bool = false,
};
pub const SyncResult = union(enum) {
ok: SyncArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses `zit sync [URL] [--user U] [--password P] [-h|--help]`: an OPTIONAL
/// positional (the peer endpoint — omitted falls back to the saved remote at
/// run time), the two value options, and the standard help request. Pure: no
/// library calls, I/O, or allocation. Help wins over every other token;
/// `--user`/`--password` each consume the following token (a dangling one is the
/// offending flag as an unknown option); a second positional is an extra
/// argument; the first offense in token order is retained for the diagnostic.
/// Mirrors `parsePushArgs` save for the URL being optional (no `no_url`).
pub fn parseSyncArgs(verb_args: []const []const u8) SyncResult {
var url: ?[]const u8 = null;
var user: ?[]const u8 = null;
var password: ?[]const u8 = null;
var help = false;
var first_err: ?SyncResult = null;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
} else if (std.mem.eql(u8, tok, "--user")) {
if (i + 1 >= verb_args.len) {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else {
i += 1;
user = verb_args[i];
}
} else if (std.mem.eql(u8, tok, "--password")) {
if (i + 1 >= verb_args.len) {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else {
i += 1;
password = verb_args[i];
}
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (url == null) {
url = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = .{ .url = url orelse "", .user = user, .password = password } };
}
/// Parsed `zit remote [URL | --unset]` arguments — the single default remote
/// (sync §4/§6), not a git-style named-remote registry. `url` empty and `unset`
/// false is the bare "print the saved remote" query; a `url` sets it; `unset`
/// clears it. Setting and clearing are mutually exclusive (a URL with `--unset`
/// is an extra argument).
pub const RemoteArgs = struct {
url: []const u8 = "",
unset: bool = false,
help: bool = false,
};
pub const RemoteResult = union(enum) {
ok: RemoteArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses `zit remote [URL] [--unset] [-h|--help]`: at most one positional (the
/// URL to save) and the `--unset` flag, mutually exclusive with a URL. Pure: no
/// library calls, I/O, or allocation. Help wins over every other token; a
/// second positional — or a URL given together with `--unset` — is an extra
/// argument; the first offense in token order is retained for the diagnostic.
pub fn parseRemoteArgs(verb_args: []const []const u8) RemoteResult {
var url: ?[]const u8 = null;
var unset = false;
var help = false;
var first_err: ?RemoteResult = null;
for (verb_args) |tok| {
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
} else if (std.mem.eql(u8, tok, "--unset")) {
unset = true;
} else if (tok.len > 1 and tok[0] == '-') {
if (first_err == null) first_err = .{ .unknown_option = tok };
} else if (url == null) {
url = tok;
} else if (first_err == null) {
first_err = .{ .extra_argument = tok };
}
}
if (help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
// Setting a URL and clearing it are contradictory: report the URL token as
// the unexpected argument (the flag it conflicts with was the intent).
if (unset and url != null) return .{ .extra_argument = url.? };
return .{ .ok = .{ .url = url orelse "", .unset = unset } };
}
pub const LeavesResult = union(enum) {
ok: LeavesArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the `zit leaves [--all] [--no-cache] [-h|--help]` surface. Pure: no library calls,
/// I/O, or allocation. `-h`/`--help` win over every otherwise-invalid token;
/// `--` switches to positional-only mode, where even an option-looking token is
/// an extra argument. The first invalid token is retained for the diagnostic.
/// The verb takes no positional, so any bare word is an extra argument.
pub fn parseLeavesArgs(verb_args: []const []const u8) LeavesResult {
const Mode = enum { scan, positional_only };
var parsed: LeavesArgs = .{};
var first_err: ?LeavesResult = null;
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "--all")) {
parsed.all = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "--no-cache")) {
parsed.no_cache = true;
continue :state .scan;
}
if (first_err == null) {
first_err = if (tok.len > 1 and tok[0] == '-')
.{ .unknown_option = tok }
else
.{ .extra_argument = tok };
}
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (first_err == null) first_err = .{ .extra_argument = tok };
continue :state .positional_only;
},
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit branch` arguments. `no_cache` bypasses the `.zit-cache` memo and
/// forces a full-log re-derive (whole-log integrity re-verified); `help`
/// requests the verbatim help block. `branch` takes no positional, so it does
/// not share `parseNoPositionalArgs` with `status`/`diff` — those verbs never
/// consult the cache, so they must not accept `--no-cache`.
pub const BranchArgs = struct {
no_cache: bool = false,
help: bool = false,
};
pub const BranchResult = union(enum) {
ok: BranchArgs,
unknown_option: []const u8,
extra_argument: []const u8,
};
/// Parses the `zit branch [--no-cache] [-h|--help]` surface. Pure: no library
/// calls, I/O, or allocation. `-h`/`--help` win over every otherwise-invalid
/// token; `--` switches to positional-only mode, where even an option-looking
/// token is an extra argument. The verb takes no positional, so any bare word
/// is an extra argument. The first invalid token is retained for the diagnostic.
pub fn parseBranchArgs(verb_args: []const []const u8) BranchResult {
const Mode = enum { scan, positional_only };
var parsed: BranchArgs = .{};
var first_err: ?BranchResult = null;
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
parsed.help = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "--no-cache")) {
parsed.no_cache = true;
continue :state .scan;
}
if (first_err == null) {
first_err = if (tok.len > 1 and tok[0] == '-')
.{ .unknown_option = tok }
else
.{ .extra_argument = tok };
}
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (first_err == null) first_err = .{ .extra_argument = tok };
continue :state .positional_only;
},
}
if (parsed.help) return .{ .ok = .{ .help = true } };
if (first_err) |e| return e;
return .{ .ok = parsed };
}
/// Parsed `zit tag list [CHECKIN]` arguments. `checkin` borrows the argv
/// token (null when the subcommand takes no positional).
pub const TagArgs = struct {
checkin: ?[]const u8 = null,
/// Bypass the `.zit-cache` memo and force a full-log re-derive.
no_cache: bool = false,
help: bool = false,
};
pub const TagResult = union(enum) {
ok: TagArgs,
unknown_option: []const u8,
extra_argument: []const u8,
/// A missing or unrecognized subcommand; the token is the offending word,
/// or an empty slice when none was given.
bad_subcommand: []const u8,
};
/// Positional classifier shared by both parser modes so the subcommand /
/// CHECKIN / extra-argument grammar lives in one place. Positional 0 is the
/// subcommand (`list` or its alias `ls`); positional 1 is the optional
/// CHECKIN; any further positional is an extra argument. The first error seen
/// is retained.
const TagScan = struct {
npos: usize = 0,
checkin: ?[]const u8 = null,
first_err: ?TagResult = null,
fn positional(self: *TagScan, tok: []const u8) void {
switch (self.npos) {
0 => if (!std.mem.eql(u8, tok, "list") and !std.mem.eql(u8, tok, "ls")) {
if (self.first_err == null) self.first_err = .{ .bad_subcommand = tok };
},
1 => self.checkin = tok,
else => if (self.first_err == null) {
self.first_err = .{ .extra_argument = tok };
},
}
self.npos += 1;
}
};
/// Parses the `zit tag list|ls [CHECKIN] [--no-cache] [-h|--help]` surface. Pure: no
/// library calls, I/O, or allocation. `-h`/`--help` win over every otherwise-
/// invalid token; `--` switches to positional-only mode, where an option-
/// looking token is a positional. The first invalid token is retained for the
/// diagnostic. A missing subcommand is a `bad_subcommand` with an empty token.
pub fn parseTagArgs(verb_args: []const []const u8) TagResult {
const Mode = enum { scan, positional_only };
var help = false;
var no_cache = false;
var scan: TagScan = .{};
var i: usize = 0;
state: switch (Mode.scan) {
.scan => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
help = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "--no-cache")) {
no_cache = true;
continue :state .scan;
}
if (tok.len > 1 and tok[0] == '-') {
if (scan.first_err == null) scan.first_err = .{ .unknown_option = tok };
continue :state .scan;
}
scan.positional(tok);
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
scan.positional(tok);
continue :state .positional_only;
},
}
if (help) return .{ .ok = .{ .help = true } };
if (scan.first_err) |e| return e;
if (scan.npos == 0) return .{ .bad_subcommand = "" };
return .{ .ok = .{ .checkin = scan.checkin, .no_cache = no_cache } };
}
/// Renders a B-init-10 usage error for `verb` to `err` and returns `.usage`.
/// General over the verb name (never special-cased to init) and best-effort
/// on the write — a diagnostic that cannot be printed must not change the
/// exit code. The trailing hint teaches where the full syntax lives.
pub fn usageError(s: Streams, verb: []const u8, kind: UsageKind, token: []const u8) Exit {
switch (kind) {
.no_paths => s.err.print("zit {s}: no paths given\n", .{verb}) catch {},
.no_checkin => s.err.print("zit {s}: no check-in given\n", .{verb}) catch {},
.no_name => s.err.print("zit {s}: no name given\n", .{verb}) catch {},
.no_file => s.err.print("zit {s}: no file given\n", .{verb}) catch {},
.no_url => s.err.print("zit {s}: no repository URL given\n", .{verb}) catch {},
.no_dir => s.err.print("zit {s}: no target directory given\n", .{verb}) catch {},
.bad_subcommand => if (token.len == 0)
s.err.print("zit {s}: no subcommand given\n", .{verb}) catch {}
else
s.err.print("zit {s}: unknown subcommand '{s}'\n", .{ verb, token }) catch {},
.unknown_option, .extra_argument => {
const label = switch (kind) {
.unknown_option => "unknown option",
.extra_argument => "unexpected argument",
.no_paths, .no_checkin, .no_name, .no_file, .no_url, .no_dir, .bad_subcommand => unreachable,
};
s.err.print("zit {s}: {s} '{s}'\n", .{ verb, label, token }) catch {};
},
}
s.err.print("try 'zit {s} --help'\n", .{verb}) catch {};
return .usage;
}
// ---------------------------------------------------------------------------
// `zit add` grammar (contracts/add.md B-add-1) + the shared working-tree-root
// probe (B-add-8) and repo-relative resolver (B-add-15). These are the CLI
// mechanism `add` (and every future working-tree verb) consumes; no add effect
// logic lives here.
// ---------------------------------------------------------------------------
/// The parsed shape of a `zit add` argument list (B-add-1). `paths` borrows the
/// argv tokens (argument order preserved); the caller owns the returned slice.
pub const AddArgs = struct {
paths: []const []const u8,
dry_run: bool = false,
quiet: bool = false,
help: bool = false,
};
/// Result of parsing add's arguments: a clean `AddArgs`, or the exact offending
/// token for the one B-add-10 usage error a pure parse can detect (an unknown
/// option). "No paths" is not an error here — an empty `paths` is the signal the
/// runner turns into B-add-9, after `--help` has had its chance to win.
pub const AddResult = union(enum) {
ok: AddArgs,
unknown_option: []const u8,
};
/// Scans `verb_args` (everything after `add`) into an `AddResult` (B-add-1) by
/// feeding the add flag spec to the shared `collectPositionals` scanner.
/// `-h/--help`, `-n/--dry-run`, `-q/--quiet` are the recognized flags; every
/// bare word (and a lone `-`) is a path, order preserved. `--help` wins over an
/// unknown option regardless of order (B-add-13), so it is checked first; only
/// when no `--help` appeared does an unknown option become the verdict.
pub fn parseAddArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!AddResult {
var args: AddArgs = .{ .paths = &.{} };
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
.{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
.{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
};
const c = try collectPositionals(gpa, verb_args, &spec);
if (!args.help) {
if (c.unknown_option) |tok| {
gpa.free(c.paths);
return .{ .unknown_option = tok };
}
}
args.paths = c.paths;
return .{ .ok = args };
}
/// The parsed shape of a `zit rm` argument list (B-rm-1). The mirror of
/// `AddArgs` — `paths` borrows the argv tokens (argument order preserved; caller
/// owns the returned slice) — plus `hard`, rm's one extra flag (`--hard` also
/// deletes the working-tree file after forgetting it).
pub const RmArgs = struct {
paths: []const []const u8,
dry_run: bool = false,
hard: bool = false,
quiet: bool = false,
help: bool = false,
};
/// Result of parsing rm's arguments: a clean `RmArgs`, or the exact offending
/// token for the one B-rm-10 usage error a pure parse can detect (an unknown
/// option). An empty `paths` is not an error here — it is the signal the runner
/// turns into B-rm-9, after `--help` has had its chance to win (B-rm-13).
pub const RmResult = union(enum) {
ok: RmArgs,
unknown_option: []const u8,
};
/// Scans `verb_args` (everything after `rm`) into an `RmResult` (B-rm-1) by
/// feeding the rm flag spec to the shared `collectPositionals` scanner — the
/// mirror of `parseAddArgs` plus `--hard` (which has no short form; the contract
/// names only `--hard`). Every bare word (and a lone `-`) is a path, order
/// preserved. `--help` wins over an unknown option regardless of order (B-rm-13),
/// so it is checked first; only when no `--help` appeared does an unknown option
/// become the verdict.
pub fn parseRmArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!RmResult {
var args: RmArgs = .{ .paths = &.{} };
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
.{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
.{ .long = "--hard", .target = .{ .flag = &args.hard } },
.{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
};
const c = try collectPositionals(gpa, verb_args, &spec);
if (!args.help) {
if (c.unknown_option) |tok| {
gpa.free(c.paths);
return .{ .unknown_option = tok };
}
}
args.paths = c.paths;
return .{ .ok = args };
}
// ---------------------------------------------------------------------------
// `zit commit` grammar (contracts/commit.md B-commit-1) + the pure timestamp
// formatter (B-commit-7) and the F-set composer (B-commit-8/9/11/12). These are
// the CLI-layer mechanism `commit` consumes; no commit effect logic (no I/O, no
// clock, no env) lives here — `runCommit` in main.zig wires them together.
// ---------------------------------------------------------------------------
/// The parsed shape of a `zit commit` argument list (B-commit-1). `message`/
/// `user` are the value-carrying options (null when absent); `paths` borrows the
/// argv tokens (argument order preserved; caller owns the returned slice) — an
/// empty `paths` means "commit every managed file" (B-commit-8), a non-empty
/// list narrows the check-in (B-commit-11).
pub const CommitArgs = struct {
message: ?[]const u8 = null,
user: ?[]const u8 = null,
/// `--branch NAME` starts (or switches to) branch NAME at this check-in
/// (repostate §6.2), null when the check-in stays on its parent's branch.
branch: ?[]const u8 = null,
/// `--close` marks the new check-in a closed leaf (artifact §4.7 `+closed`).
close: bool = false,
paths: []const []const u8,
quiet: bool = false,
help: bool = false,
};
/// Result of parsing commit's arguments: a clean `CommitArgs`, or the exact
/// offending token for the one B-commit-18b usage error a pure parse can detect
/// (an unknown option, or a value option `-m`/`--user` left dangling with no
/// following value — B-commit-1). A missing message value is `unknown_option`-
/// shaped, per B-commit-1/18b; total absence of `-m` is the distinct B-commit-5
/// message the runner raises after `--help` has had its chance to win.
pub const CommitResult = union(enum) {
ok: CommitArgs,
unknown_option: []const u8,
};
/// Scans `verb_args` (everything after `commit`) into a `CommitResult`
/// (B-commit-1) by feeding the commit flag spec to the shared
/// `collectPositionals` scanner. `-m`/`--message` and `--user` are value options
/// (each consumes the following token; a dangling one — final token, no value —
/// is recorded as an unknown option carrying that token, B-commit-1);
/// `-q`/`--quiet` and `-h`/`--help` are flags; every bare word (and a lone `-`)
/// is a path. `--help` wins over an unknown/dangling option regardless of order
/// (B-commit-18a), so it is checked first; only when no `--help` appeared does
/// the offense stand.
pub fn parseCommitArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!CommitResult {
var args: CommitArgs = .{ .paths = &.{} };
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
.{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
.{ .long = "--message", .short = "-m", .target = .{ .value = &args.message } },
.{ .long = "--user", .target = .{ .value = &args.user } },
.{ .long = "--branch", .target = .{ .value = &args.branch } },
.{ .long = "--close", .target = .{ .flag = &args.close } },
};
const c = try collectPositionals(gpa, verb_args, &spec);
if (!args.help) {
if (c.unknown_option) |tok| {
gpa.free(c.paths);
return .{ .unknown_option = tok };
}
}
args.paths = c.paths;
return .{ .ok = args };
}
/// Parsed `revert` arguments: the optional path filter (empty ⇒ every managed
/// file) and the `--help` flag.
pub const RevertArgs = struct {
paths: []const []const u8,
help: bool = false,
};
/// Result of parsing revert's arguments: a clean `RevertArgs`, or the offending
/// unknown-option token. `--help` wins over an unknown option (like commit).
pub const RevertResult = union(enum) {
ok: RevertArgs,
unknown_option: []const u8,
};
/// Scans `verb_args` (everything after `revert`) into a `RevertResult`. Every
/// bare word (and a lone `-`) is a path; `-h`/`--help` is the only flag and
/// wins over an unknown option regardless of order. The caller frees
/// `result.ok.paths`.
pub fn parseRevertArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!RevertResult {
var args: RevertArgs = .{ .paths = &.{} };
const spec = [_]FlagSpec{
.{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
};
const c = try collectPositionals(gpa, verb_args, &spec);
if (!args.help) {
if (c.unknown_option) |tok| {
gpa.free(c.paths);
return .{ .unknown_option = tok };
}
}
args.paths = c.paths;
return .{ .ok = args };
}
/// The largest Unix second whose UTC date has a 4-digit year: 9999-12-31
/// 23:59:59Z. A reading past this (or before the epoch) is clamped so the
/// writer form is always exactly 19 canonical bytes, never malformed
/// (TigerStyle: an explicit bound, never a silent overflow).
const max_writer_epoch: i64 = 253402300799;
/// Writes two decimal digits of `v` (0..99) into `buf[0..2]`, zero-padded.
fn put2(buf: []u8, v: u32) void {
std.debug.assert(buf.len >= 2 and v < 100);
buf[0] = '0' + @as(u8, @intCast((v / 10) % 10));
buf[1] = '0' + @as(u8, @intCast(v % 10));
}
/// Formats `epoch` (whole Unix seconds, UTC) into the §4.6.4 writer form
/// `YYYY-MM-DDTHH:MM:SS` (B-commit-7): a date, exactly the byte 'T', then
/// HH:MM:SS with mandatory seconds — no timezone, no leading sign, no fraction.
/// Pure arithmetic: the civil date is derived from the day count by the standard
/// days-from-civil inverse (no `std.time`, no clock — this module reads neither).
/// `epoch` is clamped to `[0, max_writer_epoch]` so a pre-epoch or far-future
/// reading still yields a 4-digit-year token. The result is a slice of `out`
/// (19 bytes); the caller owns the buffer. An in-function postcondition asserts
/// the token satisfies the library's own §4.6.4 writer oracle, so a formatting
/// bug trips here, never on the wire.
pub fn formatWriterTimestamp(epoch: i64, out: *[19]u8) []const u8 {
const secs: u64 = @intCast(std.math.clamp(epoch, 0, max_writer_epoch));
const days = secs / 86400;
const rem = secs % 86400;
// Civil date from the day count (Howard Hinnant's days-from-civil inverse):
// pure integer arithmetic, exact for the whole clamped range, no leap tables.
const z = days + 719468; // shift epoch to 0000-03-01
const era = z / 146097; // 400-year era
const doe = z - era * 146097; // day-of-era [0, 146096]
const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year-of-era
const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day-of-year (Mar-based)
const mp = (5 * doy + 2) / 153; // month, Mar=0 .. Feb=11
const day = doy - (153 * mp + 2) / 5 + 1; // day of month [1,31]
const month = if (mp < 10) mp + 3 else mp - 9; // civil month [1,12]
const year = yoe + era * 400 + @as(u64, if (month <= 2) 1 else 0);
put2(out[0..2], @intCast(year / 100));
put2(out[2..4], @intCast(year % 100));
out[4] = '-';
put2(out[5..7], @intCast(month));
out[7] = '-';
put2(out[8..10], @intCast(day));
out[10] = 'T';
put2(out[11..13], @intCast(rem / 3600));
out[13] = ':';
put2(out[14..16], @intCast((rem % 3600) / 60));
out[16] = ':';
put2(out[17..19], @intCast(rem % 60));
std.debug.assert(artifact.isWriterTimestamp(out[0..19])); // §4.6.4 writer form
return out[0..19];
}
/// Merge policy for the new check-in's F-set.
/// - `.snapshot` : the F-set IS `captured` (the managed set is
/// authoritative; a parent file absent from `captured` is a
/// DELETION and is dropped — B-commit-8/12, all-files
/// commit). `parent` is consulted only for the no-op verdict.
/// - `.carry_forward`: `captured` files, plus every `parent` file whose
/// filename is NOT in `captured`, carried forward at the
/// parent's card unchanged (B-commit-11 partial commit; a
/// root partial has an empty `parent`, so this degrades to
/// `captured`).
pub const ComposePolicy = enum { snapshot, carry_forward };
/// Value inputs to `composeFCards`. `parent` is the parent check-in's F-set
/// (empty for a root); `captured` are the files read + hashed from the working
/// tree this commit; `policy` selects the merge rule above.
pub const ComposeInput = struct {
parent: []const artifact.FCard,
captured: []const artifact.FCard,
policy: ComposePolicy,
};
/// The composed F-set and the no-op verdict.
pub const ComposeResult = struct {
/// Merged F-set, ascending by filename per §4.5.3 (`artifact.compareBytes`),
/// allocated on the caller's arena. Filenames are unique.
f: []const artifact.FCard,
/// True iff `f` is identical to `parent` on the file-set identity (filename +
/// hash + mode, entrywise, in sorted order) — the child no-op verdict
/// (B-commit-9). For a root (`parent` empty) this is exactly `f.len == 0`
/// ("nothing tracked").
unchanged: bool,
};
/// §4.5.3 ordering predicate: raw-byte ascending by filename (shorter-first on a
/// prefix tie, via `artifact.compareBytes`). Filenames are unique, so a strict
/// `.lt` total order needs no tiebreak.
fn lessByFilename(_: void, a: artifact.FCard, b: artifact.FCard) bool {
return artifact.compareBytes(a.filename, b.filename) == .lt;
}
/// The one genuinely deep, RFC-normative unit of `commit`, extracted pure so it
/// is reusable and unit-tested without a subprocess: it merges already-hashed
/// `FCard` values into the new check-in's F-set and reports the no-op verdict.
/// No I/O, no clock, no env, no hashing — it rearranges values and allocates
/// only the result array on `arena` (value-in/value-out, so it lifts verbatim
/// into a future deep `checkin.compose`). (B-commit-8/9/11/12, §4.5.3.)
///
/// Algorithm: (1) gather `captured`, plus — under `.carry_forward` — every
/// `parent` card whose filename is not among the captured filenames (unnamed
/// files carried forward unchanged); (2) sort ascending by filename (§4.5.3);
/// (3) in a safety build, drive `artifact.FCardAdmission` over the sorted
/// filenames to assert strict §4.5.2-r1 order with no reserved names — a dup or
/// mis-sort is a bug, never shipped; (4) the no-op verdict compares the result
/// to `parent` entrywise on filename+hash+mode (§4.5.3-sorted parent from a
/// parsed manifest, so the comparison is aligned). The verdict keys on
/// filename+hash+mode only; `perm`/`old_name` (set when `mv` records a rename)
/// ride through untouched, and a rename changes the filename so it is never a
/// no-op anyway — B-commit-9's name+hash+mode identity.
pub fn composeFCards(arena: std.mem.Allocator, in: ComposeInput) error{OutOfMemory}!ComposeResult {
var list: std.ArrayList(artifact.FCard) = .empty;
errdefer list.deinit(arena);
try list.appendSlice(arena, in.captured);
if (in.policy == .carry_forward) {
for (in.parent) |pc| {
var captured_here = false;
for (in.captured) |cc| {
if (std.mem.eql(u8, pc.filename, cc.filename)) {
captured_here = true;
break;
}
}
if (!captured_here) try list.append(arena, pc);
}
}
std.mem.sort(artifact.FCard, list.items, {}, lessByFilename);
// Postcondition (§4.5.2-r1 order + §4.6.3 admission): every card is admitted
// — strictly ascending, no reserved name among managed paths. Safety-gated
// so it costs nothing in ReleaseFast; a violation is a compose bug, not a
// wire condition, so a trip here is correct.
if (std.debug.runtime_safety) {
var adm: artifact.FCardAdmission = .{};
for (list.items) |card| {
const kept = adm.admit(card.filename) catch |e|
std.debug.panic("composeFCards produced out-of-order F cards: {s}", .{@errorName(e)});
std.debug.assert(kept);
}
}
const unchanged = blk: {
if (list.items.len != in.parent.len) break :blk false;
for (list.items, in.parent) |a, b| {
if (!std.mem.eql(u8, a.filename, b.filename)) break :blk false;
if (artifact.compareOptional(a.hash, b.hash) != .eq) break :blk false;
if (a.mode != b.mode) break :blk false;
}
break :blk true;
};
return .{ .f = try list.toOwnedSlice(arena), .unchanged = unchanged };
}
// --- composeCommitTags (repostate §6.2 + artifact §4.7; pure; no I/O) --------
/// The reserved `sym-` prefix (repo-state §2): a symbolic tag name is stored
/// with it. Every branch/tag control tag this commit emits carries it.
const sym_tag_prefix = "sym-";
/// Value inputs to `composeCommitTags`. `branch` non-null starts (or switches
/// to) that branch at this check-in; `parent_syms` are the parent's active
/// `sym-*` tag names to cancel so the check-in leaves the parent's branch(es)
/// — each already carrying the `sym-` prefix, and EXCLUDING the new branch's
/// own `sym-<branch>` (the caller drops it, since this commit re-establishes
/// it as a propagating tag). `close` marks the check-in a closed leaf.
pub const ComposeCommitTagsInput = struct {
branch: ?[]const u8 = null,
close: bool = false,
parent_syms: []const []const u8 = &.{},
};
/// §4.7.16 full-signed-token order: the sign byte leads (`*` 0x2A < `+` 0x2B <
/// `-` 0x2D), then the sign-stripped name (the target never breaks a tie here —
/// every card is self-referential `*`). Equivalent to comparing the whole
/// `<sign><name>` token with `artifact.compareBytes`, the key §4.7.16 mandates.
fn tagTokenLess(_: void, a: artifact.TCard, b: artifact.TCard) bool {
const sa = a.sign.byte();
const sb = b.sign.byte();
if (sa != sb) return sa < sb;
return artifact.compareBytes(a.name, b.name) == .lt;
}
/// The one deep, RFC-normative unit of `commit --branch/--close/--tag`,
/// extracted pure so it is reusable and unit-tested without a subprocess: it
/// builds the check-in's control tag run (`[]TCard`) from value inputs, then
/// sorts it into the §4.7.16 wire order. No I/O, no clock, no env; allocates
/// only the result array (and each `sym-*` name) on `arena` (value-in/value-
/// out, so it lifts verbatim into a future deep `checkin.compose`).
///
/// Cards emitted (repostate §6.2 branch creation + artifact §4.7):
/// - `branch` set: `T *branch * NAME` and `T *sym-NAME *` (both propagating,
/// §6.2) — the branch property plus its symbolic tag;
/// - one `T -sym-X *` (cancel) per parent sym X, so the check-in leaves the
/// parent's branch(es) (§6.2 sym-cancellation);
/// - `close`: `T +closed *` (a self-referential singleton, §4.7 closed leaf).
///
/// The result is sorted by the FULL signed name token (§4.7.16/§4.5.2), because
/// `artifact.create` emits `Manifest.t` verbatim (no internal sort) and the
/// commit self-check re-`parse`s the bytes — a mis-ordered run would trip the
/// §4.5.2 intra-run check. A safety build drives that order over the result.
pub fn composeCommitTags(arena: std.mem.Allocator, in: ComposeCommitTagsInput) error{OutOfMemory}![]artifact.TCard {
var list: std.ArrayList(artifact.TCard) = .empty;
errdefer list.deinit(arena);
if (in.branch) |name| {
std.debug.assert(name.len != 0);
try list.append(arena, .{ .sign = .propagate, .name = "branch", .target = null, .value = name });
const sym = try std.mem.concat(arena, u8, &.{ sym_tag_prefix, name });
try list.append(arena, .{ .sign = .propagate, .name = sym, .target = null, .value = null });
}
for (in.parent_syms) |x| {
std.debug.assert(std.mem.startsWith(u8, x, sym_tag_prefix));
// Negative space: the caller excludes the new branch's own sym, so a
// `*sym-NAME` we just added is never also cancelled here.
if (in.branch) |name|
std.debug.assert(!std.mem.eql(u8, x[sym_tag_prefix.len..], name));
try list.append(arena, .{ .sign = .cancel, .name = x, .target = null, .value = null });
}
if (in.close) {
try list.append(arena, .{ .sign = .add, .name = "closed", .target = null, .value = null });
}
std.mem.sort(artifact.TCard, list.items, {}, tagTokenLess);
// Postcondition (§4.7.16/§4.5.2): the run is STRICTLY ascending by the full
// signed token, so `artifact.create` emits it verbatim and a re-parse's
// intra-run check passes. Distinct by construction (distinct parent syms,
// sym-NAME excluded from cancels, one closed/one tag), so a non-strict pair
// is a compose bug, not a wire condition. Safety-gated — free in ReleaseFast.
if (std.debug.runtime_safety and list.items.len >= 2) {
for (list.items[0 .. list.items.len - 1], list.items[1..]) |prev, next| {
std.debug.assert(tagTokenLess({}, prev, next));
}
}
return list.toOwnedSlice(arena);
}
/// A discovered working-tree root: the opened directory handle (caller closes it
/// with `dir.close(io)`) and its absolute path (borrowed from the allocator
/// passed to `discoverRoot`).
pub const Root = struct {
dir: std.Io.Dir,
abs: []const u8,
};
/// Upper bound on how many ancestors the root probe climbs before giving up —
/// no unbounded ascent (TigerStyle: explicit fixed bound). A real checkout is
/// found in a handful of levels; 256 is far past any legitimate depth.
pub const max_root_search_depth: usize = 256;
/// Upper bound on the cwd path length this CLI will materialize. A working
/// directory deeper than the platform PATH_MAX is legal (`getcwd` grows), so we
/// grow a heap buffer up to this ceiling rather than assume one buffer suffices.
/// Bounded (TigerStyle): a path beyond this is treated as unnameable, never an
/// unbounded allocation.
pub const max_cwd_bytes: usize = 1 << 16;
/// The absolute current-directory path, materialized on `gpa` (caller frees).
/// Returns null when the cwd cannot be named — unlinked, an OS error, or longer
/// than `max_cwd_bytes` — so callers treat an unnameable cwd as "no repository
/// here" rather than aborting.
///
/// This replaces `std.process.currentPathAlloc`, which in Zig 0.16 uses a single
/// `std.fs.max_path_bytes` buffer and does `error.NameTooLong => unreachable`
/// (std/process.zig:85): a cwd deeper than that platform limit panics the whole
/// process (verified: SIGABRT on a >PATH_MAX checkout). We grow the buffer on
/// the buffer-too-small signal instead, so a deep checkout produces an honest
/// diagnostic (exit 1) rather than a crash.
pub fn currentPathAlloc(gpa: std.mem.Allocator, io: std.Io) error{OutOfMemory}!?[]u8 {
var cap: usize = std.fs.max_path_bytes;
while (cap <= max_cwd_bytes) : (cap *= 2) {
const buf = try gpa.alloc(u8, cap);
const n = std.process.currentPath(io, buf) catch |e| switch (e) {
// Buffer too small for this (deep) cwd — grow and retry.
error.NameTooLong => {
gpa.free(buf);
continue;
},
// Unlinked cwd, cancelation, or any OS error: we cannot name it.
else => {
gpa.free(buf);
return null;
},
};
return gpa.realloc(buf, n) catch {
gpa.free(buf);
return error.OutOfMemory;
};
}
return null;
}
/// Walks up from the current directory looking for the checkout root — the
/// nearest ancestor holding BOTH `.zit` (the repository) and `.zit-checkout`
/// (the working-state record) — and returns its opened handle plus absolute
/// path (B-add-8). Returns null when the filesystem root is reached with no
/// match (the caller renders "not a zit repository"). The reusable shape every
/// working-tree verb shares; not special-cased to add.
pub fn discoverRoot(gpa: std.mem.Allocator, io: std.Io) error{OutOfMemory}!?Root {
// A cwd we cannot even name is not a repository we can operate in. Uses the
// grow-buffer helper above so a deep checkout diagnoses instead of crashing.
var cur: []const u8 = (try currentPathAlloc(gpa, io)) orelse return null;
var depth: usize = 0;
while (depth < max_root_search_depth) : (depth += 1) {
if (probeRoot(io, cur)) |dir| return .{ .dir = dir, .abs = cur };
const parent = std.fs.path.dirname(cur) orelse break;
if (std.mem.eql(u8, parent, cur)) break; // filesystem root reached
cur = parent;
}
return null;
}
/// If `dir_abs` is a checkout root (both control files present), returns its
/// opened handle; otherwise closes any handle it opened and returns null. Pure
/// of allocation — the marker names are compared by `statFile`, not joined.
fn probeRoot(io: std.Io, dir_abs: []const u8) ?std.Io.Dir {
var dir = std.Io.Dir.cwd().openDir(io, dir_abs, .{}) catch return null;
if (hasEntry(io, dir, ".zit") and hasEntry(io, dir, checkout.filename)) return dir;
dir.close(io);
return null;
}
/// Whether `name` exists directly under `dir`. A missing entry (or any stat
/// failure) reads as "absent" — the probe never propagates an error, it just
/// keeps climbing.
fn hasEntry(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
_ = dir.statFile(io, name, .{ .follow_symlinks = false }) catch return false;
return true;
}
/// The outcome of resolving one `add` argument against the checkout root.
/// Distinguishes the root directory itself from a genuine escape so the caller
/// can give the right diagnostic (B-add-5's directory guidance for the root vs
/// B-add-6's "outside the repository" for an escape).
pub const Resolved = union(enum) {
/// A normalized repo-relative key (caller frees `key`).
key: []const u8,
/// `arg` denotes the checkout root directory itself (empty relative path) —
/// a directory inside the repository, e.g. `zit add .` at the root.
root,
/// `arg` escapes the checkout root: a `..` climb above it, or an absolute
/// path on another root.
outside,
};
/// Resolves `arg` (interpreted relative to `cwd_abs`) to a normalized
/// repo-relative key against `root_abs` (B-add-15): `.`/`..`/redundant
/// separators collapse, so `./a.txt`, `a.txt`, and `sub/../a.txt` all yield the
/// same key. The root itself (empty relative path) is `.root`, distinct from an
/// `.outside` escape (a `..` climb above the root, or an absolute path on
/// another root — B-add-6). The caller frees `.key`. Pure but for allocation: no
/// I/O, so it is unit-testable on strings.
pub fn repoRelative(
gpa: std.mem.Allocator,
root_abs: []const u8,
cwd_abs: []const u8,
arg: []const u8,
) error{OutOfMemory}!Resolved {
const abs = try std.fs.path.resolve(gpa, &.{ cwd_abs, arg });
defer gpa.free(abs);
const key = try std.fs.path.relative(gpa, cwd_abs, null, root_abs, abs);
// An empty relative path means `arg` IS the checkout root — a directory
// inside the repository (B-add-5), not an escape (B-add-6). This is the most
// likely novice invocation, `zit add .` at the root.
if (key.len == 0) {
gpa.free(key);
return .root;
}
if (std.fs.path.isAbsolute(key) or
std.mem.eql(u8, key, "..") or
std.mem.startsWith(u8, key, "../"))
{
gpa.free(key);
return .outside;
}
return .{ .key = key };
}
/// The top-level subcommand set — the one source of truth for the verb
/// vocabulary. The tag names ARE the on-the-wire spellings (`@tagName`), so
/// dispatch (main's total switch on this enum), help routing (main's
/// `verb_help` table keyed by this enum), and the did-you-mean suggester below
/// all derive from a single list: a new verb is added here in exactly one
/// place. `unknown` is the exhaustive catch-all that keeps the dispatch switch
/// total; it is never a real command.
pub const Verb = enum { init, add, rm, mv, status, diff, commit, log, update, revert, undo, redo, leaves, timeline, branch, tag, info, ls, cat, serve, clone, pull, push, sync, remote, pack, version, help, unknown };
/// Resolve a command-line token to its `Verb`. Spellings come from the enum
/// tag names, never a second transcription; `-h` and `--help` are the only
/// aliases (both name `help`). An unrecognized token is `.unknown`, the
/// total-switch catch-all. Pure; bounded by the fixed `Verb` value count.
pub fn verbOf(name: []const u8) Verb {
if (std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) return .help;
inline for (comptime std.enums.values(Verb)) |v| {
if (v == .unknown) continue;
if (std.mem.eql(u8, name, @tagName(v))) return v;
}
return .unknown;
}
// ---------------------------------------------------------------------------
// Unknown-command discoverability (DX D2 + D4): a git user reaches for a verb
// zit does not have. Rather than a bare "unknown command", teach the zit model
// — immutable history, propagating-tag branches, bidirectional sync — so the
// diagnostic itself corrects the mental model. Both facilities below are pure
// (no I/O, no allocation): the caller renders and owns the exit code, so the
// stderr-only / exit-2 discipline stays in one place (main's dispatch).
// ---------------------------------------------------------------------------
// The shared lessons a git muscle-memory verb should be answered with. Defined
// once and referenced from the table so several verbs teaching the same thing
// do not duplicate prose (Ousterhout: no duplication). Each renders as the tail
// of `zit: '<verb>' is not a zit command. <lesson>`.
const lesson_immutable =
"zit history is immutable — check-ins are never rewritten. Record a new " ++
"check-in that supersedes the old one, or merge instead.";
const lesson_checkout =
"zit will use 'zit update' to change the working check-out; branches are " ++
"propagating tags, not refs you detach onto.";
const lesson_sync =
"zit uses 'zit sync'; sync never discards artifacts and all history " ++
"transfers both ways, so force-push does not exist.";
const lesson_stash =
"zit has no stash; record your work in a check-in instead " ++
"('zit stash' is not implemented).";
/// Maps a common git verb that zit deliberately does not provide to the lesson
/// that corrects the underlying mental model. A gap here just means "not a
/// git-reflex verb" — the caller then falls back to `isPlanned` and, failing
/// that, the generic unknown-command path (with a did-you-mean suggestion).
const reflex_table = [_]struct { verb: []const u8, lesson: []const u8 }{
.{ .verb = "rebase", .lesson = lesson_immutable },
.{ .verb = "amend", .lesson = lesson_immutable },
.{ .verb = "reset", .lesson = lesson_immutable },
.{ .verb = "filter-branch", .lesson = lesson_immutable },
.{ .verb = "checkout", .lesson = lesson_checkout },
.{ .verb = "fetch", .lesson = lesson_sync },
.{ .verb = "stash", .lesson = lesson_stash },
};
/// Returns the educating lesson for a git-reflex `verb`, or null if `verb` is
/// not one we specifically teach against. Pure lookup over `reflex_table`.
pub fn reflexLesson(verb: []const u8) ?[]const u8 {
for (reflex_table) |entry| {
if (std.mem.eql(u8, entry.verb, verb)) return entry.lesson;
}
return null;
}
/// zit commands that are planned but not yet built. Naming a verb here (rather
/// than in the git-reflex table) says "this IS a zit command, it just isn't
/// implemented yet" — the diagnostic is a promise, not a model correction, and
/// the verb is offered as a did-you-mean target. This table upholds the DX
/// invariant that every verb the shipped help text names resolves to a real
/// handler or a stub — never a bare "unknown command". Currently empty: `clone`,
/// `pull`, and `push` are now real handlers (see `known_verbs`), and the
/// remaining sync verb (`sync`) is not yet named in any shipped help text.
const planned_verbs = [_][]const u8{};
/// Whether `verb` is a planned-but-unimplemented zit command. Pure lookup over
/// `planned_verbs`; the caller renders the promise line and owns the exit code.
pub fn isPlanned(verb: []const u8) bool {
for (planned_verbs) |v| {
if (std.mem.eql(u8, v, verb)) return true;
}
return false;
}
/// The commands zit actually dispatches, for the "did you mean" suggester —
/// derived from `Verb` (every value but the `unknown` catch-all) so the
/// suggester's candidate set can never drift from the dispatched set. Spellings
/// come from the enum tag names.
const known_verbs = blk: {
const values = std.enums.values(Verb);
var list: [values.len - 1][]const u8 = undefined;
var i: usize = 0;
for (values) |v| {
if (v == .unknown) continue;
list[i] = @tagName(v);
i += 1;
}
break :blk list;
};
/// The full set a typo can be a near-miss of: the dispatched verbs plus the
/// planned ones, so `comit` suggests the canonical `commit` rather than
/// dead-ending. `known_verbs` stays the real-handler list; suggestion is the
/// only place the two sets merge.
const suggestable_verbs = known_verbs ++ planned_verbs;
/// Longest suggestable verb — bounds the edit-distance DP row so the scan needs
/// no allocation (TigerStyle: explicit fixed bound).
const max_known_verb_len = blk: {
var m: usize = 0;
for (suggestable_verbs) |k| m = @max(m, k.len);
break :blk m;
};
/// A token longer than this is plainly not a typo of a short verb; skipping it
/// keeps the scan bounded and avoids a useless suggestion (TigerStyle: bound).
const max_suggest_input = 128;
/// git verbs whose *word* differs enough from the zit verb that edit distance
/// never connects them, yet whose *meaning* is exactly a zit verb. Mapped by
/// name so `zit remove`/`zit delete` still land on "did you mean 'rm'?" rather
/// than dead-ending. Kept tiny and explicit — a synonym table, not a fuzzy guess.
const verb_aliases = [_]struct { alias: []const u8, canonical: []const u8 }{
.{ .alias = "remove", .canonical = "rm" },
.{ .alias = "delete", .canonical = "rm" },
};
/// If `verb` is a known synonym or a near-miss (edit distance 1..2) of a real
/// command, returns the closest one so the diagnostic can say "did you mean
/// 'init'?". Returns null when nothing is close enough — a distant typo gets the
/// plain command list, not a misleading guess. Pure.
pub fn didYouMean(verb: []const u8) ?[]const u8 {
if (verb.len == 0 or verb.len > max_suggest_input) return null;
for (verb_aliases) |a| {
if (std.mem.eql(u8, a.alias, verb)) return a.canonical;
}
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (suggestable_verbs) |k| {
const d = editDistance(verb, k);
if (d < best_dist) {
best_dist = d;
best = k;
}
}
// 0 cannot occur (an exact match is not an unknown command); require 1..2 so
// only a plausible typo is suggested.
if (best_dist >= 1 and best_dist <= 2) return best;
return null;
}
/// Levenshtein distance between arbitrary input `a` and a known verb `b`, using
/// a fixed two-row buffer sized to the longest known verb. `b` MUST be one of
/// `known_verbs`; `a` is bounded by `max_suggest_input` by the sole caller.
fn editDistance(a: []const u8, b: []const u8) usize {
std.debug.assert(b.len <= max_known_verb_len);
std.debug.assert(a.len <= max_suggest_input);
var prev: [max_known_verb_len + 1]usize = undefined;
var curr: [max_known_verb_len + 1]usize = undefined;
for (0..b.len + 1) |j| prev[j] = j;
for (a, 1..) |ca, i| {
curr[0] = i;
for (b, 1..) |cb, j| {
const cost: usize = if (ca == cb) 0 else 1;
curr[j] = @min(@min(prev[j] + 1, curr[j - 1] + 1), prev[j - 1] + cost);
}
@memcpy(prev[0 .. b.len + 1], curr[0 .. b.len + 1]);
}
return prev[b.len];
}
// ---------------------------------------------------------------------------
// Unit tests — parser & exit-code mechanism (pure; no I/O). Each cites its
// behavior id (contracts/init.md).
// ---------------------------------------------------------------------------
test "cli B-init-1: bare init parses to the default directory" {
const r = parseInitArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expectEqual(@as(?[]const u8, null), r.ok.directory);
try std.testing.expect(!r.ok.quiet);
try std.testing.expect(!r.ok.help);
}
test "cli B-init-1: -q and --quiet both set quiet" {
inline for (.{ "-q", "--quiet" }) |flag| {
const r = parseInitArgs(&.{flag});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.quiet);
try std.testing.expectEqual(@as(?[]const u8, null), r.ok.directory);
}
}
test "cli B-init-1: a single positional is the directory" {
const r = parseInitArgs(&.{"proj"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("proj", r.ok.directory.?);
try std.testing.expect(!r.ok.quiet);
}
test "cli B-init-1: -- terminates option parsing so a dir named -q is reachable" {
const r = parseInitArgs(&.{ "--", "-q" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("-q", r.ok.directory.?);
try std.testing.expect(!r.ok.quiet);
}
test "cli B-init-1/-11: -h and --help set help" {
inline for (.{ "-h", "--help" }) |flag| {
const r = parseInitArgs(&.{flag});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
test "cli B-init-1: flags may precede the positional and combine" {
const r = parseInitArgs(&.{ "-q", "proj" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.quiet);
try std.testing.expectEqualStrings("proj", r.ok.directory.?);
}
test "cli B-init-10: an unknown option is reported with its exact token" {
const r = parseInitArgs(&.{"-x"});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli B-init-10: a second positional is an extra-argument error" {
const r = parseInitArgs(&.{ "a", "b" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("b", r.extra_argument);
}
test "cli B-init-10: an extra positional after -- is still an error" {
const r = parseInitArgs(&.{ "--", "a", "b" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("b", r.extra_argument);
}
test "cli B-init-1: a lone dash is a positional, not an unknown option" {
const r = parseInitArgs(&.{"-"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("-", r.ok.directory.?);
}
test "cli B-init-12: exit codes map to their uniform bytes" {
try std.testing.expectEqual(@as(u8, 0), @intFromEnum(Exit.ok));
try std.testing.expectEqual(@as(u8, 1), @intFromEnum(Exit.failure));
try std.testing.expectEqual(@as(u8, 2), @intFromEnum(Exit.usage));
}
test "cli B-init-10: usageError renders both messages with the hint to err, returns usage" {
var buf: [256]u8 = undefined;
var w = Io.Writer.fixed(&buf);
var sink: [1]u8 = undefined;
var wnull = Io.Writer.fixed(&sink);
const s: Streams = .{ .out = &wnull, .err = &w };
const code = usageError(s, "init", .unknown_option, "-x");
try std.testing.expectEqual(Exit.usage, code);
try std.testing.expectEqualStrings(
"zit init: unknown option '-x'\ntry 'zit init --help'\n",
w.buffered(),
);
}
// --- ls parser (optional VERSION; pure) -------------------------------------
test "parseLogArgs: one-positional grammar — help wins, missing/extra/unknown reject, retired -n and -- are unknown options" {
// A lone positional binds the check-in; no flags remain.
{
const r = parseLogArgs(&.{"abc"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
try std.testing.expect(!r.ok.help);
}
// No args: the positional stays null so the runner emits `no check-in`.
{
const r = parseLogArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.checkin == null);
}
// -h / --help win over everything else, including an otherwise-bad token.
inline for (.{ "-h", "--help" }) |flag| {
const r = parseLogArgs(&.{ "--nope", flag });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
// The retired `--all` is an unknown option, not a positional.
{
const r = parseLogArgs(&.{"--all"});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("--all", r.unknown_option);
}
// `--` is no longer a positional-only separator: it is an unknown option.
{
const r = parseLogArgs(&.{ "--", "abc" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("--", r.unknown_option);
}
// The retired windowing flag `-n` is now just an unknown option; its value
// becomes the positional, but the first offense (`-n`) is reported.
{
const r = parseLogArgs(&.{ "-n", "5", "abc" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-n", r.unknown_option);
}
// A second positional is an extra argument.
{
const r = parseLogArgs(&.{ "abc", "second" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("second", r.extra_argument);
}
}
test "parseLsArgs: optional VERSION, --no-cache, --help, unknown-option and extra-argument rejections" {
// No args: no version, defaults off.
{
const r = parseLsArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.version == null);
try std.testing.expect(!r.ok.no_cache);
try std.testing.expect(!r.ok.help);
}
// A lone positional is the VERSION selector.
{
const r = parseLsArgs(&.{"trunk"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("trunk", r.ok.version.?);
}
// --no-cache sets the cache-bypass flag; the positional still binds.
{
const r = parseLsArgs(&.{ "abc123", "--no-cache" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("abc123", r.ok.version.?);
try std.testing.expect(r.ok.no_cache);
}
// -h / --help win over everything else, including an otherwise-invalid token.
inline for (.{ "-h", "--help" }) |flag| {
const r = parseLsArgs(&.{ flag, "-x" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
// An unrecognized option is retained verbatim.
{
const r = parseLsArgs(&.{"-x"});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
// A second positional is an extra-argument error carrying its token.
{
const r = parseLsArgs(&.{ "v1", "v2" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("v2", r.extra_argument);
}
}
// --- cat parser (required FILE, optional VERSION; pure) ---------------------
test "parseCatArgs: a missing FILE parses ok with file==null (the runner raises no_file)" {
// Parsing is pure: an absent FILE is not a parse error — the runner turns
// `file == null` into the `.no_file` usage error (exit 2). Bare args yield a
// clean result with both positionals null and flags off.
const r = parseCatArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.file == null);
try std.testing.expect(r.ok.version == null);
try std.testing.expect(!r.ok.no_cache);
try std.testing.expect(!r.ok.help);
}
test "parseCatArgs: FILE is the first positional, VERSION the optional second; extra positional / unknown option / help precedence" {
// A lone positional binds FILE; VERSION stays null.
{
const r = parseCatArgs(&.{"a.txt"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("a.txt", r.ok.file.?);
try std.testing.expect(r.ok.version == null);
}
// Two positionals: first FILE, second VERSION. --no-cache still binds.
{
const r = parseCatArgs(&.{ "a.txt", "trunk", "--no-cache" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("a.txt", r.ok.file.?);
try std.testing.expectEqualStrings("trunk", r.ok.version.?);
try std.testing.expect(r.ok.no_cache);
}
// A third positional is an extra-argument error carrying its token.
{
const r = parseCatArgs(&.{ "a.txt", "trunk", "extra" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("extra", r.extra_argument);
}
// An unrecognized option is retained verbatim.
{
const r = parseCatArgs(&.{ "a.txt", "-x" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
// -h / --help win over everything else, including an otherwise-invalid token.
inline for (.{ "-h", "--help" }) |flag| {
const r = parseCatArgs(&.{ flag, "-x", "a.txt", "b", "c" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
// --- no-positional help parser (status/diff; pure) --------------------------
test "cli status/diff: no args parse to help=false" {
const r = parseNoPositionalArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expect(!r.ok.help);
}
test "cli status/diff: -h and --help set help" {
inline for (.{ "-h", "--help" }) |flag| {
const r = parseNoPositionalArgs(&.{flag});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
test "cli status/diff: -h wins over a trailing positional" {
const r = parseNoPositionalArgs(&.{ "-h", "x" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
test "cli status/diff: an unknown option carries its exact token" {
const r = parseNoPositionalArgs(&.{"-x"});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli status/diff: a bare positional is an extra-argument error" {
const r = parseNoPositionalArgs(&.{"foo"});
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("foo", r.extra_argument);
}
test "cli status/diff: a lone dash is an extra-argument error" {
const r = parseNoPositionalArgs(&.{"-"});
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("-", r.extra_argument);
}
test "cli status/diff: a token after -- is an extra-argument error" {
const r = parseNoPositionalArgs(&.{ "--", "foo" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("foo", r.extra_argument);
}
test "cli leaves B-leaves-1: no args parse to the default open-only listing" {
const r = parseLeavesArgs(&.{});
try std.testing.expect(r == .ok);
try std.testing.expect(!r.ok.all);
try std.testing.expect(!r.ok.help);
}
test "cli leaves §7.4: --all selects the closed-inclusive listing" {
const r = parseLeavesArgs(&.{"--all"});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.all);
try std.testing.expect(!r.ok.help);
}
test "cli leaves B-leaves-3: -h and --help set help" {
for ([_][]const u8{ "-h", "--help" }) |flag| {
const r = parseLeavesArgs(&.{flag});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
test "cli leaves B-leaves-4: --help wins over an otherwise-invalid token" {
const r = parseLeavesArgs(&.{ "-h", "-x" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
test "cli leaves B-leaves-5: an unknown option carries its exact token" {
const r = parseLeavesArgs(&.{"-x"});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli leaves B-leaves-6: a bare positional is an extra-argument error" {
const r = parseLeavesArgs(&.{"foo"});
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("foo", r.extra_argument);
}
test "cli leaves B-leaves-7: a token after -- is an extra-argument error" {
const r = parseLeavesArgs(&.{ "--", "--all" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("--all", r.extra_argument);
}
test "cli leaves: --no-cache sets no_cache and composes with --all" {
const r = parseLeavesArgs(&.{ "--all", "--no-cache" });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.all);
try std.testing.expect(r.ok.no_cache);
// The default omits it — no behavior change without the flag.
const d = parseLeavesArgs(&.{});
try std.testing.expect(!d.ok.no_cache);
}
test "cli tag B-tag-1: 'list' with no CHECKIN parses to the no-arg listing" {
const r = parseTagArgs(&.{"list"});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.checkin == null);
try std.testing.expect(!r.ok.help);
}
test "cli tag B-tag-2: 'ls' is an alias for 'list'" {
const r = parseTagArgs(&.{"ls"});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.checkin == null);
}
test "cli tag B-tag-3: 'list CHECKIN' captures the positional" {
const r = parseTagArgs(&.{ "list", "abc" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
}
test "cli tag B-tag-4: -h and --help set help" {
for ([_][]const u8{ "-h", "--help" }) |flag| {
const r = parseTagArgs(&.{ "list", flag });
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
test "cli tag B-tag-5: --help wins over a missing subcommand" {
const r = parseTagArgs(&.{"--help"});
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
test "cli tag B-tag-6: no subcommand is a bad_subcommand with an empty token" {
const r = parseTagArgs(&.{});
try std.testing.expect(r == .bad_subcommand);
try std.testing.expectEqualStrings("", r.bad_subcommand);
}
test "cli tag B-tag-7: an unrecognized subcommand carries its exact token" {
const r = parseTagArgs(&.{"add"});
try std.testing.expect(r == .bad_subcommand);
try std.testing.expectEqualStrings("add", r.bad_subcommand);
}
test "cli tag B-tag-8: an unknown option carries its exact token" {
const r = parseTagArgs(&.{ "list", "-x" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli tag B-tag-9: a second positional is an extra-argument error" {
const r = parseTagArgs(&.{ "list", "abc", "def" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("def", r.extra_argument);
}
test "cli tag B-tag-10: a token after -- is a positional" {
const r = parseTagArgs(&.{ "--", "list", "abc" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
}
test "cli tag: --no-cache sets no_cache alongside the subcommand and CHECKIN" {
const r = parseTagArgs(&.{ "list", "abc", "--no-cache" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
try std.testing.expect(r.ok.no_cache);
// Absent ⇒ false.
const d = parseTagArgs(&.{"list"});
try std.testing.expect(!d.ok.no_cache);
}
test "cli info: --no-cache sets no_cache and keeps the name positional" {
const r = parseInfoArgs(&.{ "trunk", "--no-cache" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("trunk", r.ok.name.?);
try std.testing.expect(r.ok.no_cache);
const d = parseInfoArgs(&.{"trunk"});
try std.testing.expect(!d.ok.no_cache);
}
test "cli branch: parses the empty, --no-cache, help, and error surfaces" {
// Default: no flags.
const empty = parseBranchArgs(&.{});
try std.testing.expect(empty == .ok);
try std.testing.expect(!empty.ok.no_cache and !empty.ok.help);
// --no-cache sets the bypass.
const nc = parseBranchArgs(&.{"--no-cache"});
try std.testing.expect(nc == .ok);
try std.testing.expect(nc.ok.no_cache);
// Help wins over an otherwise-invalid token.
const h = parseBranchArgs(&.{ "-x", "--help" });
try std.testing.expect(h == .ok);
try std.testing.expect(h.ok.help);
// An unknown option and a bare positional carry their exact tokens.
const opt = parseBranchArgs(&.{"-x"});
try std.testing.expect(opt == .unknown_option);
try std.testing.expectEqualStrings("-x", opt.unknown_option);
const pos = parseBranchArgs(&.{"feature"});
try std.testing.expect(pos == .extra_argument);
try std.testing.expectEqualStrings("feature", pos.extra_argument);
// After --, even --no-cache is an extra argument (no positional grammar).
const term = parseBranchArgs(&.{ "--", "--no-cache" });
try std.testing.expect(term == .extra_argument);
try std.testing.expectEqualStrings("--no-cache", term.extra_argument);
}
// --- DX D2/D4: git-reflex lessons + did-you-mean (pure) --------------------
test "cli D2: every git-reflex verb resolves to a non-empty educating lesson" {
for (reflex_table) |entry| {
const lesson = reflexLesson(entry.verb) orelse return error.TestMissingLesson;
try std.testing.expect(lesson.len > 0);
}
}
test "cli D2: a real command is not treated as a git-reflex verb" {
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("init"));
}
test "cli D2: no verb is left as a planned stub; every planned verb would resolve" {
// The planned table is currently empty — `clone` graduated to a real
// handler. Whatever it holds, each entry must resolve so no help-mentioned
// verb dead-ends.
for (planned_verbs) |v| {
try std.testing.expect(isPlanned(v));
}
// `clone` is now a real, dispatched verb — not a planned stub.
try std.testing.expectEqual(Verb.clone, verbOf("clone"));
try std.testing.expect(!isPlanned("clone"));
// A git-reflex verb and real commands are not planned either.
try std.testing.expect(!isPlanned("rebase"));
try std.testing.expect(!isPlanned("init"));
try std.testing.expect(!isPlanned("status"));
try std.testing.expect(!isPlanned("commit"));
}
test "cli undo B-undo-21: verbOf resolves undo and redo, and neither is a planned stub" {
// Both verbs join the single-source `Verb` enum in this commit; `verbOf`
// derives their spellings from the tag names, and the did-you-mean set picks
// them up automatically.
try std.testing.expectEqual(Verb.undo, verbOf("undo"));
try std.testing.expectEqual(Verb.redo, verbOf("redo"));
try std.testing.expect(!isPlanned("undo"));
try std.testing.expect(!isPlanned("redo"));
// A near-miss suggests the real verb, proving they are in the candidate set.
try std.testing.expectEqualStrings("undo", didYouMean("undp").?);
try std.testing.expectEqualStrings("redo", didYouMean("rdo").?);
}
test "cli clone: URL then DIR parse as the two positionals" {
const r = parseCloneArgs(&.{ "http://h.example/repo", "dest" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("http://h.example/repo", r.ok.url);
try std.testing.expectEqualStrings("dest", r.ok.dir);
try std.testing.expect(!r.ok.help);
}
test "cli clone: a missing URL and a missing DIR are distinct usage errors" {
try std.testing.expect(parseCloneArgs(&.{}) == .no_url);
try std.testing.expect(parseCloneArgs(&.{"http://h.example/repo"}) == .no_dir);
}
test "cli clone: --help wins over missing positionals; a third positional is extra" {
try std.testing.expect(parseCloneArgs(&.{"--help"}).ok.help);
try std.testing.expect(parseCloneArgs(&.{"-h"}).ok.help);
const extra = parseCloneArgs(&.{ "url", "dir", "surplus" });
try std.testing.expect(extra == .extra_argument);
try std.testing.expectEqualStrings("surplus", extra.extra_argument);
const bad = parseCloneArgs(&.{ "--nope", "url", "dir" });
try std.testing.expect(bad == .unknown_option);
try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}
test "cli pull: a single URL parses as the sole positional" {
const r = parsePullArgs(&.{"http://h.example/xfer"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
try std.testing.expect(!r.ok.help);
}
test "cli pull: a missing URL is a usage error; --help wins; a second positional is extra" {
try std.testing.expect(parsePullArgs(&.{}) == .no_url);
try std.testing.expect(parsePullArgs(&.{"--help"}).ok.help);
try std.testing.expect(parsePullArgs(&.{"-h"}).ok.help);
const extra = parsePullArgs(&.{ "url", "surplus" });
try std.testing.expect(extra == .extra_argument);
try std.testing.expectEqualStrings("surplus", extra.extra_argument);
const bad = parsePullArgs(&.{ "--nope", "url" });
try std.testing.expect(bad == .unknown_option);
try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}
test "cli pull: 'pull' is a dispatched verb, not a git-reflex redirect" {
try std.testing.expectEqual(Verb.pull, verbOf("pull"));
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("pull"));
try std.testing.expect(!isPlanned("pull"));
}
test "cli push: a single URL parses as the sole positional; no credential" {
const r = parsePushArgs(&.{"http://h.example/xfer"});
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
try std.testing.expect(!r.ok.help);
try std.testing.expectEqual(@as(?[]const u8, null), r.ok.user);
try std.testing.expectEqual(@as(?[]const u8, null), r.ok.password);
}
test "cli push §6: --user and --password each consume the following token, order-independent of the URL" {
const r = parsePushArgs(&.{ "--user", "alice", "http://h.example/xfer", "--password", "pw" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
try std.testing.expectEqualStrings("alice", r.ok.user.?);
try std.testing.expectEqualStrings("pw", r.ok.password.?);
// --user without --password: the password stays null (resolved later).
const only_user = parsePushArgs(&.{ "http://h.example/xfer", "--user", "bob" });
try std.testing.expectEqualStrings("bob", only_user.ok.user.?);
try std.testing.expectEqual(@as(?[]const u8, null), only_user.ok.password);
}
test "cli push: a missing URL is a usage error; --help wins; a second positional is extra; a dangling value flag is unknown" {
try std.testing.expect(parsePushArgs(&.{}) == .no_url);
try std.testing.expect(parsePushArgs(&.{"--help"}).ok.help);
try std.testing.expect(parsePushArgs(&.{"-h"}).ok.help);
const extra = parsePushArgs(&.{ "url", "surplus" });
try std.testing.expect(extra == .extra_argument);
try std.testing.expectEqualStrings("surplus", extra.extra_argument);
const bad = parsePushArgs(&.{ "--nope", "url" });
try std.testing.expect(bad == .unknown_option);
try std.testing.expectEqualStrings("--nope", bad.unknown_option);
// A `--user` with no following token is the offending flag as unknown.
const dangling = parsePushArgs(&.{ "http://h.example/xfer", "--user" });
try std.testing.expect(dangling == .unknown_option);
try std.testing.expectEqualStrings("--user", dangling.unknown_option);
}
test "cli push: 'push' is a dispatched verb, not a git-reflex redirect" {
try std.testing.expectEqual(Verb.push, verbOf("push"));
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("push"));
try std.testing.expect(!isPlanned("push"));
}
test "cli sync §4: an omitted URL positional parses (resolves to the saved remote at run time)" {
// The distinguishing property vs push: no positional is NOT a usage error.
const bare = parseSyncArgs(&.{});
try std.testing.expect(bare == .ok);
try std.testing.expectEqualStrings("", bare.ok.url); // empty → use the persisted remote
try std.testing.expectEqual(@as(?[]const u8, null), bare.ok.user);
// A given URL still parses as the sole positional, credential options intact.
const with_url = parseSyncArgs(&.{ "--user", "alice", "http://h.example/xfer", "--password", "pw" });
try std.testing.expect(with_url == .ok);
try std.testing.expectEqualStrings("http://h.example/xfer", with_url.ok.url);
try std.testing.expectEqualStrings("alice", with_url.ok.user.?);
try std.testing.expectEqualStrings("pw", with_url.ok.password.?);
}
test "cli sync: --help wins; a second positional is extra; a dangling value flag is unknown" {
try std.testing.expect(parseSyncArgs(&.{"--help"}).ok.help);
try std.testing.expect(parseSyncArgs(&.{"-h"}).ok.help);
const extra = parseSyncArgs(&.{ "url", "surplus" });
try std.testing.expect(extra == .extra_argument);
try std.testing.expectEqualStrings("surplus", extra.extra_argument);
const dangling = parseSyncArgs(&.{"--user"});
try std.testing.expect(dangling == .unknown_option);
try std.testing.expectEqualStrings("--user", dangling.unknown_option);
}
test "cli sync: 'sync' is a dispatched verb, not a git-reflex redirect" {
try std.testing.expectEqual(Verb.sync, verbOf("sync"));
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("sync"));
try std.testing.expect(!isPlanned("sync"));
}
test "cli remote: bare prints, a URL sets, and --unset clears" {
// Bare `zit remote` — neither a set nor a clear: the print query.
const bare = parseRemoteArgs(&.{});
try std.testing.expect(bare == .ok);
try std.testing.expectEqualStrings("", bare.ok.url);
try std.testing.expect(!bare.ok.unset);
// `zit remote URL` — set.
const set = parseRemoteArgs(&.{"http://h.example/xfer"});
try std.testing.expect(set == .ok);
try std.testing.expectEqualStrings("http://h.example/xfer", set.ok.url);
try std.testing.expect(!set.ok.unset);
// `zit remote --unset` — clear.
const clear = parseRemoteArgs(&.{"--unset"});
try std.testing.expect(clear == .ok);
try std.testing.expect(clear.ok.unset);
try std.testing.expectEqualStrings("", clear.ok.url);
}
test "cli remote: setting and clearing together is a usage error; --help wins; a second positional is extra" {
try std.testing.expect(parseRemoteArgs(&.{"--help"}).ok.help);
const conflict = parseRemoteArgs(&.{ "http://h.example/xfer", "--unset" });
try std.testing.expect(conflict == .extra_argument);
try std.testing.expectEqualStrings("http://h.example/xfer", conflict.extra_argument);
const extra = parseRemoteArgs(&.{ "url", "surplus" });
try std.testing.expect(extra == .extra_argument);
try std.testing.expectEqualStrings("surplus", extra.extra_argument);
const bad = parseRemoteArgs(&.{"--nope"});
try std.testing.expect(bad == .unknown_option);
try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}
test "cli remote: 'remote' is a dispatched verb, not a git-reflex redirect" {
try std.testing.expectEqual(Verb.remote, verbOf("remote"));
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("remote"));
try std.testing.expect(!isPlanned("remote"));
}
test "cli D2: a planned verb is not also a git-reflex verb (no double-classification)" {
for (planned_verbs) |v| {
try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson(v));
}
}
test "cli D4: a near-miss (edit distance 1) suggests the closest command" {
try std.testing.expectEqualStrings("init", didYouMean("ini").?);
try std.testing.expectEqualStrings("init", didYouMean("innit").?);
try std.testing.expectEqualStrings("help", didYouMean("hepl").?);
try std.testing.expectEqualStrings("version", didYouMean("versoin").?);
try std.testing.expectEqualStrings("diff", didYouMean("dif").?);
}
test "cli D4: a typo of a planned verb suggests it (planned verbs join the near-miss set)" {
try std.testing.expectEqualStrings("commit", didYouMean("comit").?);
try std.testing.expectEqualStrings("status", didYouMean("stauts").?);
try std.testing.expectEqualStrings("clone", didYouMean("clon").?);
}
test "cli rm D4: the git synonyms 'remove'/'delete' suggest 'rm'" {
// Edit distance never connects "remove"/"delete" to "rm"; the alias table
// carries the meaning across so a git user is redirected, not dead-ended.
try std.testing.expectEqualStrings("rm", didYouMean("remove").?);
try std.testing.expectEqualStrings("rm", didYouMean("delete").?);
}
test "cli D4: a distant token yields no suggestion" {
try std.testing.expectEqual(@as(?[]const u8, null), didYouMean("florble"));
try std.testing.expectEqual(@as(?[]const u8, null), didYouMean(""));
}
test "cli D4: an over-long token is not scored (bounded), yields no suggestion" {
const long = "i" ** (max_suggest_input + 1);
try std.testing.expectEqual(@as(?[]const u8, null), didYouMean(long));
}
test "cli D4: editDistance matches known small cases" {
try std.testing.expectEqual(@as(usize, 0), editDistance("init", "init"));
try std.testing.expectEqual(@as(usize, 1), editDistance("ini", "init"));
try std.testing.expectEqual(@as(usize, 2), editDistance("inti", "init"));
}
// --- mv parser (contracts/mv.md B-mv-12/-13/-14, pure; no I/O) --------------
test "cli mv B-mv-14: exactly two operands parse to OLD and NEW" {
const r = parseMvArgs(&.{ "old.txt", "new.txt" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("old.txt", r.ok.old.?);
try std.testing.expectEqualStrings("new.txt", r.ok.new.?);
try std.testing.expect(!r.ok.help);
}
test "cli mv B-mv-12: --help wins over positionals and offenses" {
inline for (.{
&[_][]const u8{ "-h", "a", "b" },
&[_][]const u8{ "a", "b", "--help" },
&[_][]const u8{ "--force", "--help", "a", "b" },
&[_][]const u8{ "a", "b", "c", "-h" }, // help beats the extra-argument offense
}) |argv| {
const r = parseMvArgs(argv);
try std.testing.expect(r == .ok);
try std.testing.expect(r.ok.help);
}
}
test "cli mv B-mv-13: an unknown option is retained as the first offense" {
const r = parseMvArgs(&.{ "--force", "a", "b" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("--force", r.unknown_option);
}
test "cli mv B-mv-14: fewer than two operands leaves an operand null" {
const none = parseMvArgs(&.{});
try std.testing.expect(none == .ok and none.ok.old == null and none.ok.new == null);
const one = parseMvArgs(&.{"only"});
try std.testing.expect(one == .ok and one.ok.new == null);
try std.testing.expectEqualStrings("only", one.ok.old.?);
}
test "cli mv B-mv-14: a third positional is an extra_argument (mv takes exactly two)" {
const r = parseMvArgs(&.{ "a", "b", "c" });
try std.testing.expect(r == .extra_argument);
try std.testing.expectEqualStrings("c", r.extra_argument);
// A token past NEW after `--` is still an extra argument.
const r2 = parseMvArgs(&.{ "--", "a", "b", "c" });
try std.testing.expect(r2 == .extra_argument);
try std.testing.expectEqualStrings("c", r2.extra_argument);
}
test "cli mv B-mv-14: -- flips to positional-only so a file named -x is an operand" {
const r = parseMvArgs(&.{ "--", "-x", "new" });
try std.testing.expect(r == .ok);
try std.testing.expectEqualStrings("-x", r.ok.old.?);
try std.testing.expectEqualStrings("new", r.ok.new.?);
}
// --- add parser (contracts/add.md B-add-1/-9/-10, pure; no I/O) -------------
/// Parses `add` args with the test allocator and asserts the ok branch; caller
/// receives the `AddArgs` and must free `args.paths`.
fn parseAddOk(verb_args: []const []const u8) !AddArgs {
const r = try parseAddArgs(std.testing.allocator, verb_args);
try std.testing.expect(r == .ok);
return r.ok;
}
test "cli add B-add-1: multiple positionals accumulate in argument order" {
const a = try parseAddOk(&.{ "a", "b", "c" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 3), a.paths.len);
try std.testing.expectEqualStrings("a", a.paths[0]);
try std.testing.expectEqualStrings("b", a.paths[1]);
try std.testing.expectEqualStrings("c", a.paths[2]);
try std.testing.expect(!a.dry_run and !a.quiet and !a.help);
}
test "cli add B-add-1: -n and --dry-run both set dry_run" {
inline for (.{ "-n", "--dry-run" }) |flag| {
const a = try parseAddOk(&.{ flag, "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.dry_run);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
}
}
test "cli add B-add-1: -q and --quiet both set quiet" {
inline for (.{ "-q", "--quiet" }) |flag| {
const a = try parseAddOk(&.{ flag, "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.quiet);
}
}
test "cli add B-add-1/-13: -h and --help set help" {
inline for (.{ "-h", "--help" }) |flag| {
const a = try parseAddOk(&.{flag});
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.help);
}
}
test "cli add B-add-1: -- flips to positional-only so a file named -q is reachable" {
const a = try parseAddOk(&.{ "--", "-q", "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(!a.quiet); // -q after -- is a path, not the flag
try std.testing.expectEqual(@as(usize, 2), a.paths.len);
try std.testing.expectEqualStrings("-q", a.paths[0]);
try std.testing.expectEqualStrings("x", a.paths[1]);
}
test "cli add B-add-1: a lone dash is a path, not an option" {
const a = try parseAddOk(&.{"-"});
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
try std.testing.expectEqualStrings("-", a.paths[0]);
}
test "cli add B-add-10: an unknown option carries its exact token" {
const r = try parseAddArgs(std.testing.allocator, &.{ "a", "-x", "b" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli add B-add-13: --help wins over an unknown option regardless of order" {
// The explicit B-add-13 precedence ("help wins over any other token") beats
// the B-add-10 unknown-option verdict, whichever token comes first.
inline for (.{
&[_][]const u8{ "--help", "-x" },
&[_][]const u8{ "-x", "--help" },
&[_][]const u8{ "-x", "-h", "y" },
}) |argv| {
const a = try parseAddOk(argv);
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.help);
}
// Without --help, the unknown option is still the verdict (unchanged).
const r = try parseAddArgs(std.testing.allocator, &.{ "-x", "y" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli add B-add-9: no positionals yields an empty paths slice" {
inline for (.{ &[_][]const u8{}, &[_][]const u8{"-q"}, &[_][]const u8{"--"} }) |argv| {
const a = try parseAddOk(argv);
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 0), a.paths.len);
}
}
test "cli add B-add-9: usageError renders the no-paths message and hint, returns usage" {
var buf: [128]u8 = undefined;
var w = Io.Writer.fixed(&buf);
var sink: [1]u8 = undefined;
var wnull = Io.Writer.fixed(&sink);
const s: Streams = .{ .out = &wnull, .err = &w };
const code = usageError(s, "add", .no_paths, "");
try std.testing.expectEqual(Exit.usage, code);
try std.testing.expectEqualStrings("zit add: no paths given\ntry 'zit add --help'\n", w.buffered());
}
// --- rm parser (contracts/rm.md B-rm-1/-9/-10/-13, pure; no I/O) ------------
/// Parses `rm` args with the test allocator and asserts the ok branch; caller
/// receives the `RmArgs` and must free `args.paths`.
fn parseRmOk(verb_args: []const []const u8) !RmArgs {
const r = try parseRmArgs(std.testing.allocator, verb_args);
try std.testing.expect(r == .ok);
return r.ok;
}
test "cli rm B-rm-1: multiple positionals accumulate in argument order" {
const a = try parseRmOk(&.{ "a", "b", "c" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 3), a.paths.len);
try std.testing.expectEqualStrings("a", a.paths[0]);
try std.testing.expectEqualStrings("b", a.paths[1]);
try std.testing.expectEqualStrings("c", a.paths[2]);
try std.testing.expect(!a.dry_run and !a.hard and !a.quiet and !a.help);
}
test "cli rm B-rm-1: -n and --dry-run both set dry_run" {
inline for (.{ "-n", "--dry-run" }) |flag| {
const a = try parseRmOk(&.{ flag, "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.dry_run);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
}
}
test "cli rm B-rm-1: --hard sets hard (no short form)" {
const a = try parseRmOk(&.{ "--hard", "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.hard);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
}
test "cli rm B-rm-1: -q and --quiet both set quiet" {
inline for (.{ "-q", "--quiet" }) |flag| {
const a = try parseRmOk(&.{ flag, "x" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.quiet);
}
}
test "cli rm B-rm-1/-13: -h and --help set help" {
inline for (.{ "-h", "--help" }) |flag| {
const a = try parseRmOk(&.{flag});
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.help);
}
}
test "cli rm B-rm-1: -- flips to positional-only so a file named -q is reachable" {
const a = try parseRmOk(&.{ "--", "-q", "--hard" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(!a.quiet and !a.hard); // flags after -- are paths
try std.testing.expectEqual(@as(usize, 2), a.paths.len);
try std.testing.expectEqualStrings("-q", a.paths[0]);
try std.testing.expectEqualStrings("--hard", a.paths[1]);
}
test "cli rm B-rm-1: a lone dash is a path, not an option" {
const a = try parseRmOk(&.{"-"});
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
try std.testing.expectEqualStrings("-", a.paths[0]);
}
test "cli rm B-rm-10: an unknown option carries its exact token" {
const r = try parseRmArgs(std.testing.allocator, &.{ "a", "-x", "b" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli rm B-rm-13: --help wins over an unknown option regardless of order" {
inline for (.{
&[_][]const u8{ "--help", "-x" },
&[_][]const u8{ "-x", "--help" },
&[_][]const u8{ "-x", "-h", "y" },
}) |argv| {
const a = try parseRmOk(argv);
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.help);
}
// Without --help, the unknown option is still the verdict (unchanged).
const r = try parseRmArgs(std.testing.allocator, &.{ "-x", "y" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-x", r.unknown_option);
}
test "cli rm B-rm-9: no positionals yields an empty paths slice" {
inline for (.{
&[_][]const u8{},
&[_][]const u8{"-q"},
&[_][]const u8{"--hard"},
&[_][]const u8{"--"},
}) |argv| {
const a = try parseRmOk(argv);
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 0), a.paths.len);
}
}
test "cli rm B-rm-9: usageError renders the no-paths message and hint, returns usage" {
var buf: [128]u8 = undefined;
var w = Io.Writer.fixed(&buf);
var sink: [1]u8 = undefined;
var wnull = Io.Writer.fixed(&sink);
const s: Streams = .{ .out = &wnull, .err = &w };
const code = usageError(s, "rm", .no_paths, "");
try std.testing.expectEqual(Exit.usage, code);
try std.testing.expectEqualStrings("zit rm: no paths given\ntry 'zit rm --help'\n", w.buffered());
}
// --- repoRelative (B-add-15 normalization / B-add-6 escape; pure) -----------
/// Resolves `arg` and asserts it yields a `.key`, returning it (caller frees).
fn repoRelKey(root_abs: []const u8, cwd_abs: []const u8, arg: []const u8) ![]const u8 {
const r = try repoRelative(std.testing.allocator, root_abs, cwd_abs, arg);
try std.testing.expect(r == .key);
return r.key;
}
test "cli add B-add-15: ./a.txt, a.txt, sub/../a.txt all normalize to one key" {
const root = "/repo";
inline for (.{ "./a.txt", "a.txt", "sub/../a.txt" }) |arg| {
const key = try repoRelKey(root, root, arg);
defer std.testing.allocator.free(key);
try std.testing.expectEqualStrings("a.txt", key);
}
}
test "cli add B-add-15: a nested path keeps its repo-relative components" {
const key = try repoRelKey("/repo", "/repo/sub", "b.txt");
defer std.testing.allocator.free(key);
try std.testing.expectEqualStrings("sub/b.txt", key);
}
test "cli add B-add-6: a .. climb above the root escapes (.outside)" {
try std.testing.expect(
(try repoRelative(std.testing.allocator, "/repo", "/repo", "../x")) == .outside,
);
}
test "cli add B-add-6: an absolute path outside the root escapes (.outside); inside resolves" {
try std.testing.expect(
(try repoRelative(std.testing.allocator, "/repo", "/repo", "/elsewhere/x")) == .outside,
);
const key = try repoRelKey("/repo", "/repo", "/repo/inside/x");
defer std.testing.allocator.free(key);
try std.testing.expectEqualStrings("inside/x", key);
}
test "cli add B-add-5: the root itself resolves to .root (a directory), not an escape" {
// `zit add .` at the checkout root: the root is a directory inside the
// repository, so it must classify as `.root` (→ the directory diagnostic),
// never `.outside` (→ "outside the repository").
try std.testing.expect(
(try repoRelative(std.testing.allocator, "/repo", "/repo", ".")) == .root,
);
try std.testing.expect(
(try repoRelative(std.testing.allocator, "/repo", "/repo", "sub/..")) == .root,
);
}
// --- commit parser (contracts/commit.md B-commit-1/-18a/-18b, pure; no I/O) --
/// Parses `commit` args with the test allocator and asserts the ok branch;
/// caller receives the `CommitArgs` and must free `args.paths`.
fn parseCommitOk(verb_args: []const []const u8) !CommitArgs {
const r = try parseCommitArgs(std.testing.allocator, verb_args);
try std.testing.expect(r == .ok);
return r.ok;
}
test "cli commit B-commit-1: -m and --message set the message value" {
inline for (.{ "-m", "--message" }) |flag| {
const a = try parseCommitOk(&.{ flag, "a fix" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqualStrings("a fix", a.message.?);
try std.testing.expectEqual(@as(usize, 0), a.paths.len);
}
}
test "cli commit B-commit-1: --user sets the author; -q sets quiet" {
const a = try parseCommitOk(&.{ "-m", "x", "--user", "alice", "-q" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqualStrings("alice", a.user.?);
try std.testing.expect(a.quiet);
}
test "cli commit B-commit-1: positionals accumulate in argument order" {
const a = try parseCommitOk(&.{ "-m", "x", "a", "b", "c" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 3), a.paths.len);
try std.testing.expectEqualStrings("a", a.paths[0]);
try std.testing.expectEqualStrings("b", a.paths[1]);
try std.testing.expectEqualStrings("c", a.paths[2]);
}
test "cli commit B-commit-1: -- flips to positional-only so a file named -q is reachable" {
const a = try parseCommitOk(&.{ "-m", "x", "--", "-q", "file" });
defer std.testing.allocator.free(a.paths);
try std.testing.expect(!a.quiet); // -q after -- is a path, not the flag
try std.testing.expectEqual(@as(usize, 2), a.paths.len);
try std.testing.expectEqualStrings("-q", a.paths[0]);
try std.testing.expectEqualStrings("file", a.paths[1]);
}
test "cli commit B-commit-1: a lone dash is a path, not an option" {
const a = try parseCommitOk(&.{ "-m", "x", "-" });
defer std.testing.allocator.free(a.paths);
try std.testing.expectEqual(@as(usize, 1), a.paths.len);
try std.testing.expectEqualStrings("-", a.paths[0]);
}
test "cli commit B-commit-18b: an unknown option carries its exact token" {
const r = try parseCommitArgs(std.testing.allocator, &.{ "-m", "x", "-z" });
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings("-z", r.unknown_option);
}
test "cli commit B-commit-1/18b: a dangling -m or --user is an unknown option" {
inline for (.{ "-m", "--message", "--user" }) |flag| {
const r = try parseCommitArgs(std.testing.allocator, &.{flag});
try std.testing.expect(r == .unknown_option);
try std.testing.expectEqualStrings(flag, r.unknown_option);
}
}
test "cli commit B-commit-18a: --help wins over an unknown option and a missing message" {
inline for (.{
&[_][]const u8{ "-z", "--help" },
&[_][]const u8{ "--help", "-z" },
&[_][]const u8{"-h"}, // no message, help still wins
&[_][]const u8{"-m"}, // dangling -m, but no --help here → not this case
}, 0..) |argv, idx| {
if (idx == 3) {
// The dangling-`-m`-without-help control: an unknown option, not ok.
const r = try parseCommitArgs(std.testing.allocator, argv);
try std.testing.expect(r == .unknown_option);
} else {
const a = try parseCommitOk(argv);
defer std.testing.allocator.free(a.paths);
try std.testing.expect(a.help);
}
}
}
// --- formatWriterTimestamp (B-commit-7; pure; §4.6.4 writer form) -----------
test "cli commit B-commit-7: a fixed epoch formats to the exact UTC writer form" {
var buf: [19]u8 = undefined;
try std.testing.expectEqualStrings("1970-01-01T00:00:00", formatWriterTimestamp(0, &buf));
// 1234567890 == 2009-02-13T23:31:30Z (the well-known Unix epoch milestone).
try std.testing.expectEqualStrings("2009-02-13T23:31:30", formatWriterTimestamp(1234567890, &buf));
// Every emitted token satisfies the library's own §4.6.4 writer oracle.
try std.testing.expect(artifact.isWriterTimestamp(formatWriterTimestamp(1234567890, &buf)));
}
test "cli commit B-commit-7: a far-future / pre-epoch reading is clamped to a 4-digit year" {
var buf: [19]u8 = undefined;
try std.testing.expectEqualStrings("9999-12-31T23:59:59", formatWriterTimestamp(max_writer_epoch, &buf));
try std.testing.expectEqualStrings("9999-12-31T23:59:59", formatWriterTimestamp(std.math.maxInt(i64), &buf));
try std.testing.expectEqualStrings("1970-01-01T00:00:00", formatWriterTimestamp(-999, &buf));
try std.testing.expect(artifact.isWriterTimestamp(formatWriterTimestamp(std.math.maxInt(i64), &buf)));
}
// --- composeFCards (B-commit-8/9/11/12, §4.5.3; pure; no I/O) ----------------
/// A regular-file F card at `name`@`hash` (slice-1 shape: no perm, no rename).
fn fc(name: []const u8, hash: ?[]const u8, mode: artifact.FileMode) artifact.FCard {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = mode };
}
/// Runs `composeFCards` on the test allocator and returns the result; caller
/// frees `result.f`.
fn compose(in: ComposeInput) !ComposeResult {
return composeFCards(std.testing.allocator, in);
}
test "cli commit B-commit-8 §4.5.3: snapshot sorts the captured set ascending by filename" {
const captured = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "ha", .regular), fc("ab", "hab", .regular) };
const r = try compose(.{ .parent = &.{}, .captured = &captured, .policy = .snapshot });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 3), r.f.len);
try std.testing.expectEqualStrings("a", r.f[0].filename); // shorter-first tie: "a" < "ab" < "b"
try std.testing.expectEqualStrings("ab", r.f[1].filename);
try std.testing.expectEqualStrings("b", r.f[2].filename);
try std.testing.expect(!r.unchanged);
}
test "cli commit B-commit-9: an empty root snapshot is the no-tracked-files no-op" {
const r = try compose(.{ .parent = &.{}, .captured = &.{}, .policy = .snapshot });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 0), r.f.len);
try std.testing.expect(r.unchanged);
}
test "cli commit B-commit-9: a child snapshot equal to its parent is the no-changes no-op" {
const parent = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hb", .regular) };
// Same name+hash+mode in a different input order → still unchanged.
const same = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "ha", .regular) };
const r0 = try compose(.{ .parent = &parent, .captured = &same, .policy = .snapshot });
defer std.testing.allocator.free(r0.f);
try std.testing.expect(r0.unchanged);
// A differing hash is a change.
const diff_hash = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hZ", .regular) };
const r1 = try compose(.{ .parent = &parent, .captured = &diff_hash, .policy = .snapshot });
defer std.testing.allocator.free(r1.f);
try std.testing.expect(!r1.unchanged);
// A differing mode (same names+hashes) is a change.
const diff_mode = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hb", .executable) };
const r2 = try compose(.{ .parent = &parent, .captured = &diff_mode, .policy = .snapshot });
defer std.testing.allocator.free(r2.f);
try std.testing.expect(!r2.unchanged);
}
test "cli commit B-commit-11: carry_forward carries unnamed parent files at the parent hash" {
const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("b", "h2", .regular) };
const captured = [_]artifact.FCard{fc("a", "h3", .regular)}; // only `a` re-hashed
const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 2), r.f.len);
try std.testing.expectEqualStrings("a", r.f[0].filename);
try std.testing.expectEqualStrings("h3", r.f[0].hash.?); // replaced
try std.testing.expectEqualStrings("b", r.f[1].filename);
try std.testing.expectEqualStrings("h2", r.f[1].hash.?); // carried at parent hash
try std.testing.expect(!r.unchanged);
}
test "cli commit B-commit-11: carry_forward replaces a named file without duplicating it" {
const parent = [_]artifact.FCard{fc("a", "h1", .regular)};
const captured = [_]artifact.FCard{fc("a", "h3", .regular)};
const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 1), r.f.len); // replaced, not duplicated
try std.testing.expectEqualStrings("h3", r.f[0].hash.?);
}
test "cli commit B-commit-11: a root partial (empty parent) captures only the named files" {
const captured = [_]artifact.FCard{fc("a", "h1", .regular)};
const r = try compose(.{ .parent = &.{}, .captured = &captured, .policy = .carry_forward });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 1), r.f.len);
try std.testing.expectEqualStrings("a", r.f[0].filename);
}
test "cli commit B-commit-8/11 §4.5.3: carry+capture interleave is re-sorted and admission-clean" {
const parent = [_]artifact.FCard{ fc("a", "ha", .regular), fc("c", "hc", .regular) };
const captured = [_]artifact.FCard{fc("b", "hb", .regular)};
const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 3), r.f.len);
try std.testing.expectEqualStrings("a", r.f[0].filename);
try std.testing.expectEqualStrings("b", r.f[1].filename);
try std.testing.expectEqualStrings("c", r.f[2].filename);
// Drive the admission oracle over the result: every card admitted (§4.5.2-r1).
var adm: artifact.FCardAdmission = .{};
for (r.f) |card| try std.testing.expect(try adm.admit(card.filename));
}
test "cli commit B-commit-12: a snapshot drops a parent file no longer captured (deletion)" {
const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("b", "h2", .regular) };
const captured = [_]artifact.FCard{fc("a", "h1", .regular)}; // `b` no longer managed
const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .snapshot });
defer std.testing.allocator.free(r.f);
try std.testing.expectEqual(@as(usize, 1), r.f.len);
try std.testing.expectEqualStrings("a", r.f[0].filename);
try std.testing.expect(!r.unchanged);
}
/// Wraps `composeFCards` for `checkAllAllocationFailures`: allocates and frees
/// the result, so every arena-allocation failure path returns `error.OutOfMemory`
/// with no leak.
fn composeOom(a: std.mem.Allocator, in: ComposeInput) !void {
const r = try composeFCards(a, in);
a.free(r.f);
}
test "cli commit B-commit-8: composeFCards is leak-free under every allocation failure" {
const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("c", "hc", .regular) };
const captured = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "h3", .regular) };
try std.testing.checkAllAllocationFailures(std.testing.allocator, composeOom, .{
ComposeInput{ .parent = &parent, .captured = &captured, .policy = .carry_forward },
});
}
// --- composeCommitTags (repostate §6.2 + artifact §4.7; pure; no I/O) --------
/// Runs `composeCommitTags` on an arena and returns the result; the arena owns
/// every card + name, so the caller frees nothing but the arena.
fn commitTags(arena: std.mem.Allocator, in: ComposeCommitTagsInput) ![]artifact.TCard {
return composeCommitTags(arena, in);
}
test "cli composeCommitTags repostate §6.2: branch-root emits *branch NAME and *sym-NAME propagating tags" {
var a = std.heap.ArenaAllocator.init(std.testing.allocator);
defer a.deinit();
// Table-driven over the RFC branch names: trunk (the root default) and a
// user branch. Each yields exactly the two §6.2 propagating tags, in
// full-token order ("*branch" < "*sym-<name>").
const cases = [_][]const u8{ "trunk", "feature" };
for (cases) |name| {
const t = try commitTags(a.allocator(), .{ .branch = name });
try std.testing.expectEqual(@as(usize, 2), t.len);
try std.testing.expectEqual(artifact.TSign.propagate, t[0].sign);
try std.testing.expectEqualStrings("branch", t[0].name);
try std.testing.expectEqual(@as(?[]const u8, null), t[0].target); // self-referential
try std.testing.expectEqualStrings(name, t[0].value.?);
try std.testing.expectEqual(artifact.TSign.propagate, t[1].sign);
try std.testing.expectEqualStrings("sym-", t[1].name[0..4]);
try std.testing.expectEqualStrings(name, t[1].name[4..]);
try std.testing.expectEqual(@as(?[]const u8, null), t[1].target);
try std.testing.expectEqual(@as(?[]const u8, null), t[1].value); // canonical `T *sym-NAME *`
}
}
test "cli composeCommitTags repostate §6.2: each inherited active sym-X yields a -sym-X cancel; NAME's own sym is not cancelled" {
var a = std.heap.ArenaAllocator.init(std.testing.allocator);
defer a.deinit();
// Parent on trunk carries the propagating sym-trunk; the caller passes it
// as a sym to cancel (having excluded sym-feature, which this commit is
// establishing). Result: *branch feature, *sym-feature, -sym-trunk.
const parent_syms = [_][]const u8{"sym-trunk"};
const t = try commitTags(a.allocator(), .{ .branch = "feature", .parent_syms = &parent_syms });
try std.testing.expectEqual(@as(usize, 3), t.len);
// Exactly one cancel, naming the parent sym; no cancel of sym-feature.
var cancels: usize = 0;
for (t) |card| {
if (card.sign == .cancel) {
cancels += 1;
try std.testing.expectEqualStrings("sym-trunk", card.name);
}
try std.testing.expect(!(card.sign == .cancel and std.mem.eql(u8, card.name, "sym-feature")));
}
try std.testing.expectEqual(@as(usize, 1), cancels);
}
test "cli composeCommitTags artifact §4.7.16/§4.5.2: cards ordered by full signed token round-trip through artifact.parse" {
var a = std.heap.ArenaAllocator.init(std.testing.allocator);
defer a.deinit();
const parent_syms = [_][]const u8{"sym-trunk"};
const t = try commitTags(a.allocator(), .{ .branch = "feature", .close = true, .parent_syms = &parent_syms });
// Order: "*branch" < "*sym-feature" < "+closed" < "-sym-trunk".
try std.testing.expectEqual(@as(usize, 4), t.len);
try std.testing.expectEqualStrings("branch", t[0].name);
try std.testing.expectEqualStrings("sym-feature", t[1].name);
try std.testing.expectEqualStrings("closed", t[2].name);
try std.testing.expectEqualStrings("sym-trunk", t[3].name);
try std.testing.expectEqual(artifact.TSign.add, t[2].sign);
try std.testing.expectEqual(artifact.TSign.cancel, t[3].sign);
// Round-trip: a check-in manifest carrying exactly this run must parse back
// without the §4.5.2 intra-run T check rejecting it (the guarantee the
// commit self-check relies on).
try expectTagRunRoundTrips(std.testing.allocator, t);
}
test "cli composeCommitTags artifact §4.7: --close adds a single +closed self-referential singleton" {
var a = std.heap.ArenaAllocator.init(std.testing.allocator);
defer a.deinit();
// No branch, no parent syms: a plain child commit that only closes its leaf.
const t = try commitTags(a.allocator(), .{ .close = true });
try std.testing.expectEqual(@as(usize, 1), t.len);
try std.testing.expectEqual(artifact.TSign.add, t[0].sign);
try std.testing.expectEqualStrings("closed", t[0].name);
try std.testing.expectEqual(@as(?[]const u8, null), t[0].target); // self-referential
try std.testing.expectEqual(@as(?[]const u8, null), t[0].value);
// A no-flag child commit carries no control tags at all (today's bytes).
const none = try commitTags(a.allocator(), .{});
try std.testing.expectEqual(@as(usize, 0), none.len);
}
/// Wraps `composeCommitTags` for `checkAllAllocationFailures`: the arena frees
/// every card + name, so each allocation-failure path returns `error.OutOfMemory`
/// with no leak.
fn composeTagsOom(gpa: std.mem.Allocator) !void {
var a = std.heap.ArenaAllocator.init(gpa);
defer a.deinit();
const parent_syms = [_][]const u8{ "sym-trunk", "sym-old" };
_ = try composeCommitTags(a.allocator(), .{ .branch = "feature", .close = true, .parent_syms = &parent_syms });
}
test "cli composeCommitTags §8 OOM: leak-free under checkAllAllocationFailures" {
try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
fn run(gpa: std.mem.Allocator) !void {
return composeTagsOom(gpa);
}
}.run, .{});
}
/// Serializes a check-in manifest carrying `tags` (via `artifact.create`) and
/// parses it back, asserting the T run survives the §4.5.2 intra-run check in
/// the exact order given. The commit path depends on this round-trip.
fn expectTagRunRoundTrips(gpa: std.mem.Allocator, tags: []const artifact.TCard) !void {
const f = [_]artifact.FCard{fc("a.txt", "a" ** 40, .regular)};
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .manifest,
.a = null,
.b = null,
.c = .{ .comment = "m" },
.d = .{ .timestamp = "2020-01-01T00:00:00" },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &f,
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.t = tags,
};
defer m.deinit();
const bytes = try artifact.create(&m, gpa);
defer gpa.free(bytes);
var parsed = try artifact.parse(gpa, bytes);
defer parsed.deinit();
try std.testing.expectEqual(tags.len, parsed.t.len);
for (tags, parsed.t) |want, got| {
try std.testing.expectEqual(want.sign, got.sign);
try std.testing.expectEqualStrings(want.name, got.name);
}
}