Skip to content
zit Artifact [8dfbc8034b]
Login

Artifact [8dfbc8034b]

Artifact 8dfbc8034b3e85496d2bb51e8d54120a6d4997213ea420e1b4f70ab19bdb5d2b:


//! 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("checkout.zig");

/// 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 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. `unknown_option`
/// and `extra_argument` carry an offending token; `no_paths` (add's B-add-9
/// "no paths given") carries none. General over the verb — no per-verb
/// special-casing lives in the runner.
pub const UsageKind = enum { unknown_option, extra_argument, no_paths };

/// Scans `verb_args` (everything after the verb) into a `ParseResult`
/// (B-init-1). Pure: no library calls, no I/O, no allocation. A labeled
/// switch drives the two parser states — `.scan` honors flags, `--` switches
/// to `.positional_only` where every remaining token is a positional (so a
/// directory literally named `-q` is reachable). Recognized flags set state;
/// the first non-flag becomes the directory; a second positional or an
/// unrecognized `-x` option is a usage error carrying its token.
pub fn parseInitArgs(verb_args: []const []const u8) ParseResult {
    const Mode = enum { scan, positional_only };
    var parsed: Parsed = .{};
    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, "-q") or std.mem.eql(u8, tok, "--quiet")) {
                parsed.quiet = true;
                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] == '-') return .{ .unknown_option = tok };
            if (parsed.directory == null) {
                parsed.directory = tok;
            } else {
                return .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (parsed.directory == null) {
                parsed.directory = tok;
            } else {
                return .{ .extra_argument = tok };
            }
            continue :state .positional_only;
        },
    }
    return .{ .ok = parsed };
}

/// The parsed shape of a `zit status` argument list (B-status-1). `status` has
/// exactly one flag; a dedicated result avoids dragging `Parsed`'s
/// never-settable `directory`/`quiet` into a verb that supports neither (the
/// least-obscurity choice — no latent, unreachable fields).
pub const StatusResult = union(enum) {
    ok: struct { help: bool },
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Scans `verb_args` (everything after `status`) into a `StatusResult`
/// (B-status-1). Pure: no library calls, no I/O, no allocation. A labeled switch
/// drives the two states — `.scan` honors `-h/--help`; `--` flips to
/// `.positional_only`; in `.scan` any other `-x` (not a lone `-`) is
/// `unknown_option` (B-status-12); any positional token (a bare word, a lone
/// `-`, or anything after `--`) is `extra_argument` (B-status-13). `status`
/// takes NO positional, so this is `parseInitArgs`' scanner with the
/// directory-accepting branches turned into errors.
///
/// The whole token list is scanned before any verdict is returned so that `-h`
/// wins over an offending token regardless of order (B-status-14: help wins over
/// everything, matching add's B-add-13). Only the FIRST offending token is
/// carried, so the diagnostic names the same token the user reads left-to-right.
pub fn parseStatusArgs(verb_args: []const []const u8) StatusResult {
    const Mode = enum { scan, positional_only };
    var help = false;
    var first_err: ?StatusResult = 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;
            }
            // Any other token that starts with '-' (and is not a lone "-") is an
            // unrecognized option; a lone "-" and every bare word is a positional,
            // which `status` never accepts. Record the first offense and keep
            // scanning so a later `-h` can still win.
            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;
            // After `--` every token is a positional, so an option-looking token
            // is `extra_argument`, not `unknown_option`.
            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 } };
}

/// 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 offending token: the whole invocation is short a required path.
        .no_paths => s.err.print("zit {s}: no paths given\n", .{verb}) catch {},
        .unknown_option, .extra_argument => {
            const label = switch (kind) {
                .unknown_option => "unknown option",
                .extra_argument => "unexpected argument",
                .no_paths => 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). A
/// labeled switch drives the two parser states — `.scan` honors flags, `--`
/// switches to `.positional_only` where every remaining token is a path (so a
/// file literally named `-q` is reachable). `-h/--help`, `-n/--dry-run`,
/// `-q/--quiet` are recognized in scan mode; any other `-x` (not a lone `-`) is
/// an unknown option carrying its token; a lone `-` and every bare word is a
/// path (paths accumulate, order preserved). The only allocation is the
/// slice-of-borrows collecting the paths.
///
/// The whole token list is scanned before any verdict is returned so that
/// `--help` wins over an unknown option regardless of order (B-add-13's explicit
/// precedence: help wins over any other token). The first unknown option is
/// remembered and only becomes the result when no `--help` was seen anywhere.
pub fn parseAddArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!AddResult {
    const Mode = enum { scan, positional_only };
    var paths: std.ArrayList([]const u8) = .empty;
    errdefer paths.deinit(gpa);
    var args: AddArgs = .{ .paths = &.{} };
    var first_unknown: ?[]const u8 = 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")) {
                args.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run")) {
                args.dry_run = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-q") or std.mem.eql(u8, tok, "--quiet")) {
                args.quiet = true;
                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 path.
            // Record (don't yet return) the first unknown so a later `--help`
            // can still win — the scan continues either way (B-add-13).
            if (tok.len > 1 and tok[0] == '-') {
                if (first_unknown == null) first_unknown = tok;
                continue :state .scan;
            }
            try paths.append(gpa, tok);
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            try paths.append(gpa, verb_args[i]);
            i += 1;
            continue :state .positional_only;
        },
    }
    // Help wins over everything, including an unknown option (B-add-13); only
    // when no `--help` appeared does an unknown option become the verdict.
    if (!args.help) {
        if (first_unknown) |tok| {
            paths.deinit(gpa);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = try paths.toOwnedSlice(gpa);
    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). The
/// mirror of `parseAddArgs`: a labeled switch drives the two parser states —
/// `.scan` honors flags, `--` switches to `.positional_only` where every
/// remaining token is a path (so a file literally named `-q` is reachable).
/// `-h/--help`, `-n/--dry-run`, `--hard`, `-q/--quiet` are recognized in scan
/// mode; any other `-x` (not a lone `-`) is an unknown option carrying its
/// token; a lone `-` and every bare word is a path (order preserved). The only
/// allocation is the slice-of-borrows collecting the paths.
///
/// The whole token list is scanned before any verdict is returned so that
/// `--help` wins over an unknown option regardless of order (B-rm-13). The first
/// unknown option is remembered and only becomes the result when no `--help` was
/// seen anywhere. `--hard` has no short form (the contract names only `--hard`).
pub fn parseRmArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!RmResult {
    const Mode = enum { scan, positional_only };
    var paths: std.ArrayList([]const u8) = .empty;
    errdefer paths.deinit(gpa);
    var args: RmArgs = .{ .paths = &.{} };
    var first_unknown: ?[]const u8 = 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")) {
                args.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run")) {
                args.dry_run = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--hard")) {
                args.hard = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-q") or std.mem.eql(u8, tok, "--quiet")) {
                args.quiet = true;
                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 path.
            // Record (don't yet return) the first unknown so a later `--help`
            // can still win — the scan continues either way (B-rm-13).
            if (tok.len > 1 and tok[0] == '-') {
                if (first_unknown == null) first_unknown = tok;
                continue :state .scan;
            }
            try paths.append(gpa, tok);
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            try paths.append(gpa, verb_args[i]);
            i += 1;
            continue :state .positional_only;
        },
    }
    // Help wins over everything, including an unknown option (B-rm-13); only when
    // no `--help` appeared does an unknown option become the verdict.
    if (!args.help) {
        if (first_unknown) |tok| {
            paths.deinit(gpa);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = try paths.toOwnedSlice(gpa);
    return .{ .ok = args };
}

/// 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 };
}

// ---------------------------------------------------------------------------
// 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 = "push", .lesson = lesson_sync },
    .{ .verb = "pull", .lesson = lesson_sync },
    .{ .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". Concretely, `init
/// --help` points the user at their first `zit commit`, so `commit` lives here;
/// `clone` is planned too. (`status` is a real handler — see `known_verbs`.)
const planned_verbs = [_][]const u8{ "commit", "clone" };

/// 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. Kept
/// beside the suggester (not derived from main's `Verb` enum) so the pure DX
/// mechanism has no dependency back on the dispatcher.
const known_verbs = [_][]const u8{ "init", "add", "rm", "status", "version", "help" };

/// 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(),
    );
}

// --- status parser (B-status-1/-12/-13/-14; pure) --------------------------

test "cli status B-status-1: no args parses to help=false" {
    const r = parseStatusArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.help);
}

test "cli status B-status-1/-14: -h and --help set help" {
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseStatusArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli status B-status-14: -h wins over a trailing positional" {
    const r = parseStatusArgs(&.{ "-h", "x" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.help);
}

test "cli status B-status-12: an unknown option carries its exact token" {
    const r = parseStatusArgs(&.{"-x"});
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli status B-status-13: a bare positional is an extra-argument error" {
    const r = parseStatusArgs(&.{"foo"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

test "cli status B-status-13: a lone dash is an extra-argument error (no positional accepted)" {
    const r = parseStatusArgs(&.{"-"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("-", r.extra_argument);
}

test "cli status B-status-13: a token after -- is an extra-argument error" {
    const r = parseStatusArgs(&.{ "--", "foo" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.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: every planned verb is recognized as planned (help-text invariant)" {
    // `commit` is named in init's help text; `clone` is planned too. Each must
    // resolve as planned so no help-mentioned verb dead-ends.
    for ([_][]const u8{ "commit", "clone" }) |v| {
        try std.testing.expect(isPlanned(v));
    }
    // A git-reflex verb, a real command, and the now-implemented `status` are
    // not planned.
    try std.testing.expect(!isPlanned("rebase"));
    try std.testing.expect(!isPlanned("init"));
    try std.testing.expect(!isPlanned("status"));
}

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").?);
}

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"));
}

// --- 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,
    );
}