Skip to content
zit Artifact [2240b6f3ec]
Login

Artifact [2240b6f3ec]

Artifact 2240b6f3ec434cf35b3c9ec7afa94feb77cd7d5d01cb88e62a6c810d152a5738:


//! zit — command-line entry point.
//!
//! The heavy lifting lives in the `zit` library module (src/root.zig and the
//! per-RFC / storage modules it re-exports); this file is only the CLI surface.
//! `main` wires I/O and flushes; `run` is a pure-ish dispatcher over an
//! injected `std.Io` that returns an exit code, so every verb is exercisable
//! without a subprocess (design-seams rule). No `std.http` appears here.

const std = @import("std");
const Io = std.Io;
const zit = @import("zit");
const cli = @import("cli.zig");

// Storage-layer modules the CLI consumes. They depend on the `zit` module
// (for delta/artifact) rather than living inside it, so — exactly like
// dst.zig and the benchmark — the exe reaches them by relative import, not
// through the public `zit` surface.
const logstore = @import("logstore.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
// The managed-path set is a first-class library module (re-exported by
// root.zig), so it is reached through the public `zit` surface, not by relative
// import. `add` is a pure consumer of it.
const managed = zit.managed;

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const arena = init.arena.allocator();
    const io = init.io;

    // `toSlice` yields sentinel-terminated argv; widen to plain `[]const u8`
    // slices so the verb runner never depends on NUL termination (a slice of
    // `[:0]const u8` does not coerce to a slice of `[]const u8`).
    const argv_z = try init.minimal.args.toSlice(arena);
    const args = try arena.alloc([]const u8, argv_z.len);
    for (argv_z, args) |src, *dst| dst.* = src;

    // Streaming (not positional) writers: `Io.File.Writer.init` defaults to
    // `.positional`, which writes via `pwrite` from an internal `pos` that
    // starts at 0 — ignoring the OS file offset zit inherited on stdout/stderr.
    // That silently clobbers `zit … >> logfile` (O_APPEND) and grouped/shared-fd
    // redirections (`{ echo a; zit …; } > log`), overwriting bytes another
    // writer already put there. Streaming mode uses plain `write`/`writev`,
    // honoring the inherited offset and O_APPEND — the pipe-safe, composable
    // behavior a process's standard streams must have (contracts/rm.md B-rm-14).
    var out_buf: [4096]u8 = undefined;
    var out_writer: Io.File.Writer = .initStreaming(.stdout(), io, &out_buf);
    var err_buf: [4096]u8 = undefined;
    var err_writer: Io.File.Writer = .initStreaming(.stderr(), io, &err_buf);
    const streams: cli.Streams = .{ .out = &out_writer.interface, .err = &err_writer.interface };

    const code = run(gpa, io, arena, args, streams);

    streams.out.flush() catch {};
    streams.err.flush() catch {};
    if (code != .ok) std.process.exit(@intFromEnum(code));
}

/// The top-level subcommand set. `unknown` is the exhaustive catch-all so the
/// dispatch switch below is total.
const Verb = enum { init, add, rm, version, help, unknown };

fn verbOf(name: []const u8) Verb {
    if (std.mem.eql(u8, name, "init")) return .init;
    if (std.mem.eql(u8, name, "add")) return .add;
    if (std.mem.eql(u8, name, "rm")) return .rm;
    if (std.mem.eql(u8, name, "version")) return .version;
    if (std.mem.eql(u8, name, "help") or std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) return .help;
    return .unknown;
}

/// Dispatches on the verb (a total switch) and returns the process exit code.
/// Never errors: every failure is rendered as an educating diagnostic by the
/// verb and folded into the returned `Exit`.
fn run(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const verb = if (args.len >= 2) args[1] else "help";
    switch (verbOf(verb)) {
        .init => return runInit(gpa, io, arena, args[2..], streams),
        .add => return runAdd(gpa, io, arena, args[2..], streams),
        .rm => return runRm(gpa, io, arena, args[2..], streams),
        .version => {
            streams.out.writeAll("zit 0.0.0 (targets Fossil RFC rev " ++ zit.rfc_revision ++ ")\n") catch {};
            return .ok;
        },
        .help => {
            streams.out.writeAll(top_usage) catch {};
            return .ok;
        },
        .unknown => {
            // A git muscle-memory verb (rebase, checkout, push, …) gets a
            // lesson that corrects the zit model instead of a bare error, so
            // the diagnostic teaches (DX D2). Still a usage error: exit 2,
            // stderr only, nothing on stdout.
            if (cli.reflexLesson(verb)) |lesson| {
                streams.err.print("zit: '{s}' is not a zit command. {s}\n", .{ verb, lesson }) catch {};
                return .usage;
            }
            // A planned-but-unbuilt zit command (e.g. `commit`, which init's
            // help points the user at) resolves to a promise, not a dead-end:
            // every verb the shipped help names owns a handler or this stub.
            if (cli.isPlanned(verb)) {
                streams.err.print("zit: '{s}' is planned but not implemented yet.\n", .{verb}) catch {};
                return .usage;
            }
            // Otherwise the generic path: name the unknown command, offer a
            // near-miss suggestion when one is close (DX D4), then the command
            // list so discoverability survives a growing verb set.
            streams.err.print("zit: unknown command '{s}'\n", .{verb}) catch {};
            if (cli.didYouMean(verb)) |suggestion| {
                streams.err.print("did you mean '{s}'?\n", .{suggestion}) catch {};
            }
            streams.err.writeAll("\n") catch {};
            streams.err.writeAll(top_usage) catch {};
            return .usage;
        },
    }
}

/// `zit init [-q|--quiet] [--] [directory]` — the thin wiring: parse, resolve
/// an absolute target, then drive the existing library sequence (create `.zit`,
/// `LogStore.open`/`commit`/`deinit`, `checkout.write`). Filesystem access is
/// confined to this function. All-or-nothing: any failure after `.zit` is
/// created rolls it back so a failed init never poisons the already-exists
/// guard (B-init-9). (contracts/init.md B-init-1..12.)
fn runInit(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-init-1). Usage errors → exit 2 (B-init-10).
    const parsed = switch (cli.parseInitArgs(verb_args)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "init", .unknown_option, tok),
        .extra_argument => |tok| return cli.usageError(streams, "init", .extra_argument, tok),
    };
    // --help wins over every effect (B-init-11).
    if (parsed.help) {
        streams.out.writeAll(init_help) catch {};
        return .ok;
    }

    // 2. Resolve an absolute target directory and repo path (B-init-2/5/12).
    //    `resolve` normalizes `.`/`..` and, when `dir_arg` is already
    //    absolute, ignores the cwd — yielding a stable, parseable success token.
    const dir_arg = parsed.directory orelse ".";
    // Uses the grow-buffer helper (not std's `currentPathAlloc`, which aborts on
    // a cwd deeper than the platform PATH_MAX). A cwd we cannot name is reported
    // honestly, never a crash.
    const cwd = (cli.currentPathAlloc(arena, io) catch |e|
        return renderStorage(streams, dir_arg, reasonOf(e))) orelse
        return renderStorage(streams, dir_arg, "cannot determine the current directory");
    const abs_dir = std.fs.path.resolve(arena, &.{ cwd, dir_arg }) catch |e|
        return renderStorage(streams, dir_arg, reasonOf(e));
    const abs_zit = std.fs.path.join(arena, &.{ abs_dir, repo_filename }) catch |e|
        return renderStorage(streams, abs_dir, reasonOf(e));

    // 3. Ensure the target directory exists (B-init-5); a file in the way is
    //    "not a directory" (B-init-8).
    const cwd_dir = std.Io.Dir.cwd();
    cwd_dir.createDirPath(io, abs_dir) catch |e| switch (e) {
        error.NotDir => return renderNotDir(streams, abs_dir),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };

    // 4. Open a handle to the target dir; all subsequent file ops go through it
    //    so `.zit`/`.zit-checkout` land in the resolved directory.
    var target = cwd_dir.openDir(io, abs_dir, .{}) catch |e| switch (e) {
        error.NotDir => return renderNotDir(streams, abs_dir),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };
    defer target.close(io);

    // 5. Create `.zit` exclusively — this defines the "already exists" error
    //    out of existence (no separate stat probe, no TOCTOU). Detection is
    //    precisely the presence of `.zit`; an orphan `.zit-checkout` is
    //    irrelevant (B-init-7).
    var file = target.createFile(io, repo_filename, .{ .read = true, .exclusive = true }) catch |e| switch (e) {
        error.PathAlreadyExists => return renderAlreadyExists(streams, abs_zit),
        error.IsDir, error.NotDir => return renderNotDir(streams, abs_zit),
        else => return renderStorage(streams, abs_dir, reasonOf(e)),
    };

    // 6. Default hash policy (B-init-3): a fresh repo with no SHA-named
    //    artifacts is SHA3-256 (`.sha3` = 2). Threaded opaquely into the header.
    const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false));

    // 7. Log lifecycle (B-init-2): `open` on the empty file writes the 64-byte
    //    header (zero frames); `commit` fsyncs it (durability before success).
    //    On any failure, close and roll back `.zit` (B-init-9).
    var fs = storage.FileStorage.init(io, file, 0);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), hash_kind) catch |e| {
        file.close(io);
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };
    log.commit() catch |e| {
        log.deinit(gpa);
        file.close(io);
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };
    log.deinit(gpa);
    file.close(io);

    // 8. Checkout record (B-init-2/4): trunk unborn (`version == ""`).
    //    `checkout.write` is itself atomic, so a partial record is impossible;
    //    on failure roll back `.zit` (B-init-9).
    checkout.write(io, target, .{ .version = "", .branch = "trunk" }) catch |e| {
        target.deleteFile(io, repo_filename) catch {};
        return renderStorage(streams, abs_dir, reasonOf(e));
    };

    // 9. Success feedback (B-init-2/6/12): a single plain-ASCII line whose
    //    trailing whitespace-delimited token is the absolute `.zit` path;
    //    `-q`/`--quiet` writes nothing.
    if (!parsed.quiet) {
        streams.out.print("Initialized empty zit repository in {s}\n", .{abs_zit}) catch {};
    }
    return .ok;
}

/// The per-path outcome of classifying one `add` argument. A tagged union so
/// the runner renders each case to the correct stream and folds it into the
/// exit code, with the managed-set key carried on the two cases that print it.
const Outcome = union(enum) {
    /// Newly inserted; `added <key>` to stdout (`would add` under -n).
    added: []const u8,
    /// Already in the set; informational notice to stderr (B-add-3).
    already_tracked: []const u8,
    no_such_file, // B-add-4
    is_directory, // B-add-5
    not_regular, // B-add-5
    outside_root, // B-add-6
    control_file, // B-add-7
    /// A bound was exceeded on insert (TooLong/TooMany) or allocation failed;
    /// rendered by `addFail` with the offending arg (B-add-17).
    insert_error: managed.ManagedError,
};

/// `zit add [-n] [-q] [--] <path>...` — the thin wiring: parse, discover the
/// checkout root, load the managed set, classify/insert each named path in
/// argument order (do-as-much-as-possible), persist once atomically, and fold
/// the per-path outcomes into one exit code. Touches working-copy state only;
/// no log append, no network. (contracts/add.md B-add-1..18.)
fn runAdd(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-add-1). --help wins over everything, including a missing path
    //    (B-add-13); an empty path list is the B-add-9 usage error, detected
    //    before any repo discovery or I/O.
    const parsed = switch (cli.parseAddArgs(gpa, verb_args) catch
        return addFail(streams, "", error.OutOfMemory)) {
        .ok => |p| p,
        .unknown_option => |tok| return cli.usageError(streams, "add", .unknown_option, tok),
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(add_help) catch {};
        return .ok;
    }
    if (parsed.paths.len == 0) return cli.usageError(streams, "add", .no_paths, "");

    // 2. Discover the checkout root (B-add-8). Absent → not a zit repository.
    const root = (cli.discoverRoot(arena, io) catch
        return addFail(streams, "", error.OutOfMemory)) orelse {
        streams.err.writeAll(
            "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        ) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    // Uses the grow-buffer helper (not std's panicking `currentPathAlloc`); a
    // cwd we cannot name after discovery succeeded is a storage failure.
    const cwd_abs = (cli.currentPathAlloc(arena, io) catch
        return addFail(streams, "", error.OutOfMemory)) orelse
        return addFail(streams, "", error.Storage);

    // 3. Load the current set (absent file ⇒ empty; B-add-17 on a bad read).
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return addFail(streams, "", e);
    defer set.deinit();

    // 4. Process each path in argument order (B-add-2..7/-11/-12/-15).
    var failed = false;
    var changed = false;
    for (parsed.paths) |arg| {
        const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, arg) catch {
            failed = true;
            _ = addFail(streams, arg, error.OutOfMemory);
            continue;
        };
        switch (outcome) {
            .added => |key| {
                changed = true;
                if (!parsed.quiet) {
                    const verb_word: []const u8 = if (parsed.dry_run) "would add" else "added";
                    streams.out.print("{s} {s}\n", .{ verb_word, key }) catch {};
                }
            },
            .already_tracked => |key| {
                if (!parsed.quiet)
                    streams.err.print("zit: '{s}' is already tracked\n", .{key}) catch {};
            },
            .no_such_file => {
                failed = true;
                streams.err.print("zit add: '{s}': no such file\n", .{arg}) catch {};
            },
            .is_directory => {
                failed = true;
                streams.err.print(
                    "zit add: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n",
                    .{arg},
                ) catch {};
            },
            .not_regular => {
                failed = true;
                streams.err.print("zit add: '{s}' is not a regular file\n", .{arg}) catch {};
            },
            .outside_root => {
                failed = true;
                streams.err.print("zit add: '{s}' is outside the repository\n", .{arg}) catch {};
            },
            .control_file => {
                failed = true;
                streams.err.print(
                    "zit add: '{s}' is a zit control file and cannot be tracked\n",
                    .{arg},
                ) catch {};
            },
            .insert_error => |e| {
                failed = true;
                _ = addFail(streams, arg, e);
            },
        }
    }

    // 5. Persist once, atomically, only if the set actually changed and this is
    //    not a dry run (B-add-11/-16). A write failure leaves the prior file
    //    wholly intact (ManagedSet.write is write-temp → fsync → rename).
    if (changed and !parsed.dry_run) {
        set.write(io, root_dir) catch |e| return addFail(streams, "", e);
    }

    // 6. Fold outcomes into one code (B-add-18): valid additions persist even
    //    when a sibling path failed (partial success).
    return if (failed) .failure else .ok;
}

/// Classifies one raw `add` argument into an `Outcome` — all of add's
/// filesystem access lives here (design-seams rule). Order is chosen so the most
/// specific message wins: resolve to a repo-relative key (escape → outside),
/// reject control files, stat for existence/kind, then membership/insert. Only
/// allocation failure escapes as an error; every other condition is an
/// `Outcome`.
fn classifyPath(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_abs: []const u8,
    root_dir: std.Io.Dir,
    cwd_abs: []const u8,
    set: *managed.ManagedSet,
    arg: []const u8,
) error{OutOfMemory}!Outcome {
    // 1. Normalize to a repo-relative key. An escape is B-add-6; the root dir
    //    itself is B-add-5's directory case (e.g. `zit add .` at the root), not
    //    an escape.
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .outside_root,
        .root => return .is_directory,
        .key => |k| k,
    };
    // 2. A key beyond the managed-set bound cannot be tracked (B-add-17
    //    error.TooLong). Checked here, not via stat: a name-too-long key makes
    //    the OS stat fail with NameTooLong, which would otherwise mask the honest
    //    "path is too long" message as "no such file". Same bound
    //    `ManagedSet.insert` enforces (`managed.max_path_len`).
    if (key.len > managed.max_path_len) return .{ .insert_error = error.TooLong };
    // 3. The three control files are never version-controlled (B-add-7). Checked
    //    before stat because they exist as real files and would otherwise add.
    if (isControlFile(key)) return .control_file;
    // 4. Existence and file-kind (B-add-4/-5). Symlinks are not followed so a
    //    symlink is rejected as non-regular, not silently resolved to its target.
    const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch
        return .no_such_file;
    switch (st.kind) {
        .directory => return .is_directory,
        .file => {},
        else => return .not_regular,
    }
    // 5. Membership then insert (B-add-2/-3). `contains` first keeps a re-add
    //    error-free; `insert`'s false branch is race-defensive idempotency.
    if (set.contains(key)) return .{ .already_tracked = key };
    const inserted = set.insert(key) catch |e| return .{ .insert_error = e };
    return if (inserted) .{ .added = key } else .{ .already_tracked = key };
}

/// Whether `key` names one of the three repo-internal control files, which are
/// never tracked (B-add-7): `.zit`, `.zit-checkout`, `.zit-managed`.
fn isControlFile(key: []const u8) bool {
    return std.mem.eql(u8, key, repo_filename) or
        std.mem.eql(u8, key, checkout.filename) or
        std.mem.eql(u8, key, managed.filename);
}

/// Renders a B-add-17 managed-state failure for `arg` to stderr and returns
/// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new
/// variant fails to compile, catching drift. `arg` is used only by `TooLong`
/// (the per-path bound); the read/write-context errors carry no path.
fn addFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.writeAll(
            "zit add: cannot read tracked files: .zit-managed is corrupt\n",
        ) catch {},
        error.Storage => s.err.writeAll(
            "zit add: cannot update tracked files: input/output error\n",
        ) catch {},
        error.TooLong => s.err.print("zit add: '{s}': path is too long to track\n", .{arg}) catch {},
        error.TooMany => s.err.writeAll("zit add: too many tracked files\n") catch {},
        error.OutOfMemory => s.err.writeAll("zit add: out of memory\n") catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit rm` — add's mirror over the per-checkout managed-path set. Forgets named
// paths (default: disk untouched); `--hard` also deletes the working file after
// the set is durably written. Thin consumer: no logic added to any library
// module, no `std.http`. (contracts/rm.md B-rm-1..19.)
// ==========================================================================

/// The per-path outcome of classifying one `rm` argument. A tagged union so the
/// runner renders each case to the correct stream and folds it into the exit
/// code. `forgotten`/`not_tracked` carry the normalized managed-set key (the
/// identity `contains`/`remove` operate on and, per B-rm-15, that `rm` reports);
/// the three resolution-time rejects carry no payload and are rendered against
/// the raw arg (exactly as `add` renders its rejects).
const RmOutcome = union(enum) {
    /// Was in the set and is now removed; `removed <key>` (`would remove` -n).
    forgotten: []const u8,
    /// A resolved key not in the set (B-rm-4 error notice to stderr).
    not_tracked: []const u8,
    is_directory, // B-rm-5 (the checkout root itself)
    outside_root, // B-rm-6
    control_file, // B-rm-7
};

/// `zit rm [-n] [--hard] [-q] [--] <path>...` — the thin wiring: parse, discover
/// the checkout root, load the managed set, classify/forget each named path in
/// argument order (do-as-much-as-possible), persist once atomically, then —
/// strictly after a successful write — delete the `--hard` working files. Folds
/// the per-path outcomes into one exit code. Touches working-copy state only
/// (`.zit-managed`, and under `--hard` the named files); no log append, no
/// network. (contracts/rm.md B-rm-1..19.)
fn runRm(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    // 1. Parse (B-rm-1). --help wins over everything, including a missing path
    //    (B-rm-13); an empty path list is the B-rm-9 usage error, detected
    //    before any repo discovery or I/O (B-rm-9). Unknown option → B-rm-10.
    const parsed = switch (cli.parseRmArgs(gpa, verb_args) catch
        return rmFail(streams, "", error.OutOfMemory)) {
        .ok => |p| p,
        .unknown_option => |tok| {
            // A git user reaches for `--force`/`-f`, but `rm` is safe by default
            // (files are kept on disk) so forcing is meaningless. Teach the model
            // in one line instead of a bare "unknown option" (DX). Still a usage
            // error: exit 2, stderr only.
            if (std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f")) {
                streams.err.writeAll(
                    "zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n",
                ) catch {};
                return .usage;
            }
            return cli.usageError(streams, "rm", .unknown_option, tok);
        },
    };
    defer gpa.free(parsed.paths);
    if (parsed.help) {
        streams.out.writeAll(rm_help) catch {};
        return .ok;
    }
    if (parsed.paths.len == 0) return cli.usageError(streams, "rm", .no_paths, "");

    // 2. Discover the checkout root (B-rm-8). Absent → not a zit repository.
    const root = (cli.discoverRoot(arena, io) catch
        return rmFail(streams, "", error.OutOfMemory)) orelse {
        streams.err.writeAll(
            "zit: not a zit repository (no .zit found in this directory or any parent)\n",
        ) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io); // kept open for the --hard unlink pass
    const cwd_abs = (cli.currentPathAlloc(arena, io) catch
        return rmFail(streams, "", error.OutOfMemory)) orelse
        return rmFail(streams, "", error.Storage);

    // 3. Load the current set (absent file ⇒ empty; B-rm-17 on a bad read).
    var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
        return rmFail(streams, "", e);
    defer set.deinit();

    // 4. Process each path in argument order (B-rm-2/-4..7/-11/-15). Collect the
    //    forgotten keys (in argument order) rather than reporting them inline:
    //    the `removed`/`would remove` stdout lines are WITHHELD until the atomic
    //    write has succeeded (step 5b), so a persistence failure (B-rm-17) never
    //    claims a removal that did not happen — `zit rm <files> | wc -l` counts
    //    exactly the forgets that were durably written (B-rm-14). The same list
    //    drives the `--hard` unlink pass (B-rm-16: persist before destroy).
    //    Per-path error/notice diagnostics still print inline: they are
    //    independent of the write and belong on stderr regardless of its fate.
    var failed = false;
    var changed = false;
    var forgotten: std.ArrayList([]const u8) = .empty;
    for (parsed.paths) |arg| {
        const outcome = classifyRmPath(arena, root.abs, cwd_abs, &set, arg) catch {
            failed = true;
            _ = rmFail(streams, arg, error.OutOfMemory);
            continue;
        };
        switch (outcome) {
            .forgotten => |key| {
                changed = true;
                forgotten.append(arena, key) catch return rmFail(streams, arg, error.OutOfMemory);
            },
            .not_tracked => |key| {
                failed = true;
                streams.err.print("zit rm: '{s}' is not tracked\n", .{key}) catch {};
            },
            .is_directory => {
                failed = true;
                streams.err.print(
                    "zit rm: '{s}' is a directory; name individual files (directory recursion is not yet supported)\n",
                    .{arg},
                ) catch {};
            },
            .outside_root => {
                failed = true;
                streams.err.print("zit rm: '{s}' is outside the repository\n", .{arg}) catch {};
            },
            .control_file => {
                failed = true;
                streams.err.print(
                    "zit rm: '{s}' is a zit control file and cannot be tracked\n",
                    .{arg},
                ) catch {};
            },
        }
    }

    // 5. Persist once, atomically, only if the set changed and this is not a dry
    //    run (B-rm-16). Returning on a write failure guarantees NOTHING below
    //    runs — neither the stdout report (5b) nor the --hard unlink (6): a write
    //    failure claims no removal on stdout and unlinks nothing, leaving every
    //    file both on disk and still tracked (B-rm-16/-17/-14).
    if (changed and !parsed.dry_run) {
        set.write(io, root_dir) catch |e| return rmFail(streams, "", e);
    }

    // 5b. Report the forgets on stdout — only now that they are durable (or, in a
    //     dry run, never persisted but truthfully previewed). Argument order is
    //     preserved (the collection order). Held until here so a failed write
    //     never prints a `removed` line for a forget that did not survive
    //     (B-rm-2/-11/-14). Under a real `--hard` run the disposition is NOT yet
    //     known — it depends on the per-file unlink outcome — so those lines are
    //     withheld and emitted in step 6, keyed on the actual deletion, so the
    //     `(deleted from disk)` suffix can never claim a deletion that failed
    //     (B-rm-3/-4/-14/-18). A dry run never unlinks, so `-n --hard` previews
    //     `(deleted from disk)` here truthfully (subjunctive; B-rm-11).
    const defer_report = parsed.hard and !parsed.dry_run;
    if (!parsed.quiet and !defer_report) {
        // D1/D6: state the resulting file disposition in the line so the user is
        // never left guessing whether their working copy survived, while keeping
        // the leading `removed <key>` / `would remove <key>` tokens stable so the
        // output stays pipe-parseable and `zit rm <files> | wc -l` still counts
        // one line per removal (B-rm-2/-11/-14). The verb word tracks `-n`; the
        // suffix tracks `--hard` (only reachable here as a dry-run preview).
        const verb_word: []const u8 = if (parsed.dry_run) "would remove" else "removed";
        const disposition: []const u8 =
            if (parsed.hard) "(deleted from disk)" else "(file kept on disk)";
        for (forgotten.items) |key|
            streams.out.print("{s} {s} {s}\n", .{ verb_word, key, disposition }) catch {};
    }

    // 6. --hard: delete each forgotten working file, strictly after the write
    //    succeeded (B-rm-3/-16/-18). An already-absent file is success (the "no
    //    working file" postcondition holds). The stdout `removed <key> (deleted
    //    from disk)` line is emitted HERE, keyed on the REAL outcome: only a
    //    deleted (or already-gone) file prints it (B-rm-3); a file that could not
    //    be unlinked is a failed path, so its diagnostic goes to stderr and
    //    NOTHING about it reaches stdout (B-rm-4/-14/-18) — the disposition suffix
    //    can never lie. The forget itself already stands and is persisted (honest
    //    partial). `-q` suppresses the stdout line but never the error.
    if (defer_report) {
        for (forgotten.items) |key| {
            const deleted = if (root_dir.deleteFile(io, key)) |_|
                true
            else |e| switch (e) {
                error.FileNotFound => true, // already gone ⇒ postcondition holds (B-rm-3)
                else => blk: {
                    failed = true;
                    streams.err.print(
                        "zit rm: '{s}': removed from tracking but could not delete from disk: {s}\n",
                        .{ key, reasonOf(e) },
                    ) catch {};
                    break :blk false;
                },
            };
            if (deleted and !parsed.quiet)
                streams.out.print("removed {s} (deleted from disk)\n", .{key}) catch {};
        }
    }

    // 7. Fold outcomes into one code (B-rm-19): successful forgets persist even
    //    when a sibling path failed (partial success).
    return if (failed) .failure else .ok;
}

/// Classifies one raw `rm` argument into an `RmOutcome`. Filesystem-free (unlike
/// `add`'s `classifyPath`): membership in the set decides, not the disk, so a
/// managed path whose working file the user already deleted is still forgettable
/// (contracts/rm.md Model). Order is chosen so the most specific message wins:
/// resolve to a repo-relative key (escape → outside, root → directory), reject
/// control files, then membership. Only allocation failure escapes as an error.
fn classifyRmPath(
    gpa: std.mem.Allocator,
    root_abs: []const u8,
    cwd_abs: []const u8,
    set: *managed.ManagedSet,
    arg: []const u8,
) error{OutOfMemory}!RmOutcome {
    // 1. Normalize to a repo-relative key. An escape is B-rm-6; the root dir
    //    itself is B-rm-5's directory case (e.g. `zit rm .` at the root).
    const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
        .outside => return .outside_root,
        .root => return .is_directory,
        .key => |k| k,
    };
    // 2. The three control files are never version-controlled and must never be
    //    `--hard`-unlinked (B-rm-7). Checked BEFORE membership: a belt-and-
    //    suspenders guard so even a corrupt set holding a control-file key could
    //    not get it deleted. No max_path_len guard is needed (unlike add): an
    //    over-length key is simply absent from the set → falls to not_tracked.
    if (isControlFile(key)) return .control_file;
    // 3. Membership then forget (B-rm-2/-4). `remove` returns whether it was
    //    present (managed.zig:114): true ⇒ forgotten, false ⇒ never tracked. It
    //    is a no-op on an absent key, so the membership verdict and the forget
    //    fold into one call — no separate `contains` needed.
    if (set.remove(key)) return .{ .forgotten = key };
    return .{ .not_tracked = key };
}

/// Renders a B-rm-17 managed-state failure for `arg` to stderr and returns
/// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new
/// variant fails to compile, catching drift. Only `Corrupt`/`Storage`/
/// `OutOfMemory` are reachable from `rm`'s flow (`remove` never errors; `insert`
/// is never called), so `TooLong`/`TooMany` exist only for exhaustiveness.
fn rmFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.writeAll(
            "zit rm: cannot read tracked files: .zit-managed is corrupt\n",
        ) catch {},
        error.Storage => s.err.writeAll(
            "zit rm: cannot update tracked files: input/output error\n",
        ) catch {},
        error.TooLong => s.err.print("zit rm: '{s}': path is too long to track\n", .{arg}) catch {},
        error.TooMany => s.err.writeAll("zit rm: too many tracked files\n") catch {},
        error.OutOfMemory => s.err.writeAll("zit rm: out of memory\n") catch {},
    }
    return .failure;
}

/// The repository file's fixed name (logstore §E: a copied `.zit` is a
/// complete repository).
const repo_filename: []const u8 = ".zit";

// --- educating diagnostics (all to err, all exit 1) -----------------------

fn renderAlreadyExists(s: cli.Streams, abs_zit: []const u8) cli.Exit {
    s.err.print("zit: a zit repository already exists at {s}\n", .{abs_zit}) catch {};
    return .failure;
}

fn renderNotDir(s: cli.Streams, path: []const u8) cli.Exit {
    s.err.print("zit: cannot initialize: {s} is not a directory\n", .{path}) catch {};
    return .failure;
}

fn renderStorage(s: cli.Streams, abs_dir: []const u8, reason: []const u8) cli.Exit {
    s.err.print("zit: cannot initialize repository at {s}: {s}\n", .{ abs_dir, reason }) catch {};
    return .failure;
}

/// Maps a raw error to a short, honest, human phrase for the B-init-9 line.
/// Pure rendering, not logic — the switch is over the union of every error
/// the init path can surface (filesystem, `LogStore`, `checkout`).
fn reasonOf(err: anyerror) []const u8 {
    return switch (err) {
        error.OutOfMemory => "out of memory",
        error.AccessDenied, error.PermissionDenied => "permission denied",
        error.ReadOnlyFileSystem => "read-only file system",
        error.NoSpaceLeft, error.DiskQuota => "no space left on device",
        error.FileNotFound => "no such file or directory",
        error.Corrupt => "repository is corrupt",
        // `error.Storage` (LogStore/checkout device failure) and every other
        // device/OS error collapse to one honest phrase.
        else => "input/output error",
    };
}

// --- help / usage text ----------------------------------------------------

const top_usage =
    \\zit — a Fossil version-control system in Zig
    \\
    \\usage: zit <command> [args]
    \\
    \\commands:
    \\  init       create an empty zit repository
    \\  add        mark files to include in your next check-in
    \\  rm         stop tracking files (undo add)
    \\  version    print the version
    \\  help       show this help
    \\
;

/// Verbatim B-init-11 help text. Model-honest wording ("check-in", "trunk",
/// "unborn", "root check-in", "SHA3-256 by default"). Ends in exactly one
/// newline. The e2e suite verifies stdout equals an independent transcription
/// of the contract, so drift here is caught.
const init_help =
    \\usage: zit init [-q|--quiet] [--] [directory]
    \\
    \\Create an empty zit repository in <directory> (default: the current
    \\directory). Writes two files there:
    \\
    \\  .zit            the repository — a single, self-contained content log
    \\  .zit-checkout   the working-state record (branch trunk, no check-in yet)
    \\
    \\The new repository has no check-ins: the default branch 'trunk' is unborn
    \\until your first `zit commit`, which records the root check-in. Artifacts
    \\are named with SHA3-256 by default.
    \\
    \\If <directory> does not exist it is created.
    \\
    \\options:
    \\  -q, --quiet   do not print the confirmation line
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  repository created
    \\  1  a repository already exists, or the path could not be initialized
    \\  2  usage error (unknown option or extra argument)
    \\
;

/// Verbatim B-add-13 help text (contracts/add.md §Help). Model-honest wording
/// ("managed", "no staging area", "check-in", "trunk", "unborn", "zit rm").
/// Ends in exactly one newline. The e2e suite compares stdout against an
/// independent transcription of this block, so drift is caught.
const add_help =
    \\usage: zit add [-n|--dry-run] [-q|--quiet] [--] <path>...
    \\
    \\Mark one or more working-tree files as managed, so they are included in
    \\your next check-in. zit has no staging area: `add` records only *which*
    \\files to track — their contents are read from the working tree when you
    \\run `zit commit`, not now. Re-adding an already-tracked file does nothing.
    \\
    \\Files stay tracked until `zit rm` forgets them. You can add files before
    \\the first check-in (the default branch 'trunk' is unborn until then).
    \\
    \\Only individual files are accepted; naming a directory is an error.
    \\
    \\options:
    \\  -n, --dry-run   show what would be added; change nothing
    \\  -q, --quiet     do not print the added files
    \\  -h, --help      show this help and exit
    \\
    \\exit status:
    \\  0  all named files are now tracked (or already were)
    \\  1  a file could not be added (missing, a directory, outside the repo),
    \\     or this is not a zit repository
    \\  2  usage error (no files given, or an unknown option)
    \\
;

/// Verbatim B-rm-13 help text (contracts/rm.md §Help). Model-honest wording
/// ("tracking", "managed set", "no staging area", "check-in", and the explicit
/// contrast with git's delete-by-default). Ends in exactly one newline. The e2e
/// suite compares stdout against an independent transcription of this block.
const rm_help =
    \\usage: zit rm [-n|--dry-run] [--hard] [-q|--quiet] [--] <path>...
    \\
    \\Stop tracking one or more files: remove their paths from the managed set,
    \\so your next check-in records their deletion. zit has no staging area, so
    \\this takes effect when you run `zit commit`, not now.
    \\
    \\By default your working copy is left on disk untouched -- `rm` only forgets
    \\the file. Pass --hard to also delete the file from disk. (git deletes by
    \\default; zit keeps your file unless you ask for --hard.)
    \\
    \\Removing a path that is not tracked is an error. Only individual files are
    \\accepted; naming a directory is an error.
    \\
    \\options:
    \\  -n, --dry-run   show what would be removed; change nothing
    \\      --hard      also delete the working-tree file from disk
    \\  -q, --quiet     do not print the removed files
    \\  -h, --help      show this help and exit
    \\
    \\exit status:
    \\  0  all named files are no longer tracked (and, with --hard, deleted)
    \\  1  a file could not be removed (not tracked, a directory, outside the
    \\     repo, or --hard could not delete it), or this is not a zit repository
    \\  2  usage error (no files given, or an unknown option)
    \\
;

test {
    // Pull the CLI harness's inline unit tests into `zig build test`.
    _ = @import("cli.zig");
}

// --- add error-mapper unit test (B-add-17; pure switch) --------------------

test "add B-add-17: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .arg = "", .want = "zit add: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .arg = "", .want = "zit add: cannot update tracked files: input/output error\n" },
        .{ .err = error.TooLong, .arg = "deep/file", .want = "zit add: 'deep/file': path is too long to track\n" },
        .{ .err = error.TooMany, .arg = "x", .want = "zit add: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .arg = "x", .want = "zit add: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        const code = addFail(s, c.arg, c.err);
        try std.testing.expectEqual(cli.Exit.failure, code);
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

// --- rm error-mapper unit test (B-rm-17; pure switch) ----------------------

test "rm B-rm-17: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .arg = "", .want = "zit rm: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .arg = "", .want = "zit rm: cannot update tracked files: input/output error\n" },
        .{ .err = error.TooLong, .arg = "deep/file", .want = "zit rm: 'deep/file': path is too long to track\n" },
        .{ .err = error.TooMany, .arg = "x", .want = "zit rm: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .arg = "x", .want = "zit rm: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [128]u8 = undefined;
        var w = Io.Writer.fixed(&buf);
        var sink: [1]u8 = undefined;
        var wnull = Io.Writer.fixed(&sink);
        const s: cli.Streams = .{ .out = &wnull, .err = &w };
        const code = rmFail(s, c.arg, c.err);
        try std.testing.expectEqual(cli.Exit.failure, code);
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "init B-init-3: default hash_kind is SHA3-256 (= 2)" {
    // Guards the byte the header stores for a fresh repo (artifact §7.2-m2).
    try std.testing.expectEqual(@as(u8, 2), @intFromEnum(zit.artifact.defaultPolicy(false, false)));
}

// --- unknown-command dispatch (DX D2/D4), verified through `run()` ----------
//
// `run()` on an unknown verb touches neither io nor the allocators, so these
// drive it in-process over fixed-buffer writers — no subprocess needed — and
// assert the same exit-2 / stderr-only contract the e2e suite holds for init.

/// Runs `zit <verb>` through `run()`, returning the exit code plus the captured
/// stdout/stderr slices (valid until the next call — backed by static buffers,
/// so a test uses one invocation at a time).
const RunCapture = struct {
    code: cli.Exit,
    out: []const u8,
    err: []const u8,

    var out_buf: [256]u8 = undefined;
    var err_buf: [512]u8 = undefined;

    fn run_verb(verb: []const u8) RunCapture {
        var out = Io.Writer.fixed(&out_buf);
        var err = Io.Writer.fixed(&err_buf);
        const streams: cli.Streams = .{ .out = &out, .err = &err };
        const code = run(std.testing.allocator, std.testing.io, std.testing.allocator, &.{ "zit", verb }, streams);
        return .{ .code = code, .out = out.buffered(), .err = err.buffered() };
    }
};

/// Asserts a git-reflex `verb` renders its teaching line: exit 2, stdout empty,
/// stderr naming the verb, stating it is not a zit command, and containing the
/// lesson keyword `teaches`.
fn expectReflex(verb: []const u8, teaches: []const u8) !void {
    const r = RunCapture.run_verb(verb);
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, verb) != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "is not a zit command") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, teaches) != null);
}

/// Asserts a planned-but-unimplemented `verb` renders the exact promise line:
/// exit 2, stdout empty, stderr `zit: '<verb>' is planned but not implemented
/// yet.\n` and nothing else. A planned verb is a real zit command that simply
/// isn't built yet (e.g. `commit`, which `init --help` points the user at), so
/// unlike a git-reflex verb it is never told it "is not a zit command".
fn expectPlanned(verb: []const u8) !void {
    const r = RunCapture.run_verb(verb);
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    var buf: [128]u8 = undefined;
    const expected = try std.fmt.bufPrint(&buf, "zit: '{s}' is planned but not implemented yet.\n", .{verb});
    try std.testing.expectEqualStrings(expected, r.err);
}

test "cli D2 B-init-12: 'rebase' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("rebase", "history is immutable");
}
test "cli D2 B-init-12: 'amend' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("amend", "history is immutable");
}
test "cli D2 B-init-12: 'reset' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("reset", "history is immutable");
}
test "cli D2 B-init-12: 'filter-branch' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("filter-branch", "history is immutable");
}
test "cli D2 B-init-12: 'checkout' points at 'zit update', exit 2 stderr-only" {
    try expectReflex("checkout", "zit update");
}
test "cli D2 B-init-12: 'push' teaches bidirectional sync / no force-push, exit 2 stderr-only" {
    try expectReflex("push", "force-push does not exist");
}
test "cli D2 B-init-12: 'pull' teaches bidirectional sync, exit 2 stderr-only" {
    try expectReflex("pull", "zit sync");
}
test "cli D2 B-init-12: 'fetch' teaches bidirectional sync, exit 2 stderr-only" {
    try expectReflex("fetch", "zit sync");
}
test "cli D2 B-init-12: 'stash' states it is not implemented, exit 2 stderr-only" {
    try expectReflex("stash", "not implemented");
}
test "cli D2 B-init-12: 'commit' is a planned verb, not a bare unknown command" {
    // Regression: `init --help` points the user at their first `zit commit`,
    // so the binary must own that verb as a planned stub, not disown it.
    try expectPlanned("commit");
}
test "cli D2 B-init-12: 'status' is a planned verb, exit 2 stderr-only" {
    try expectPlanned("status");
}
test "cli D2 B-init-12: 'clone' is a planned verb, exit 2 stderr-only" {
    try expectPlanned("clone");
}

test "cli D4 B-init-12: a near-miss verb suggests the closest command, exit 2 stderr-only" {
    const r = RunCapture.run_verb("ini");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'ini'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'init'?") != null);
}

test "cli D4 B-init-12: a typo of a planned verb suggests the canonical command" {
    // `comit` is neither reflex nor exactly planned; the generic path should
    // suggest the planned canonical `commit` (planned verbs join the near-miss
    // set), so the typo does not dead-end.
    const r = RunCapture.run_verb("comit");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'comit'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'commit'?") != null);
}

test "cli D4 B-init-12: a distant unknown lists commands with no misleading guess" {
    const r = RunCapture.run_verb("florble");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "unknown command 'florble'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean") == null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "commands:") != null);
}