Skip to content
zit Artifact [f56d577d1b]
Login

Artifact [f56d577d1b]

Artifact f56d577d1bfdfeb4ff6a3d7b612ae082b289b2547e865aa0f53dd7df990a788d:


//! 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 File = Io.File;
const zit = @import("zit");
const cli = @import("cli.zig");
const merge3 = @import("merge3.zig");
const extras = @import("extras.zig");

// The store layers and the repository facade are all part of the one `zit`
// library module (root.zig re-exports them), so the CLU imports a single
// dependency. `Repository` is the deep read-path facade; `zit.repoview` is its
// derived-view collaborator, reached directly only by the pack verify test.
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const update = @import("zit").update;
const checkout = @import("zit").checkout;
const bisect = @import("zit").bisect;
const Repository = @import("zit").Repository;
const serve_mod = @import("serve.zig");
// The clone verb's two CLI-layer seams: `transport` is the sole HTTP/IO module
// on the client side (sync §4), `sync_driver` the sans-IO round loop that pumps
// `sync.Client` to §3.7 convergence over it. `sync.zig` itself stays sans-IO.
const transport = @import("transport.zig");
const sync_driver = @import("sync_driver.zig");
const cmd_sync = @import("cmd/sync.zig");
const cmd_tracking = @import("cmd/tracking.zig");
const cmd_history = @import("cmd/history.zig");
const cmd_checkout = @import("cmd/checkout.zig");
const cmd_verify = @import("cmd/verify.zig");
const repostate = zit.repostate;
const managed = zit.managed;
// The artifact-format vocabulary `commit` assembles a check-in from: the
// `Manifest`/card structs it populates, `create` (serializes + computes the Z
// self-check), `parse` (reads the parent F-set), and `artifactName` (names each
// blob and the manifest). Reached through the same `zit` module cli.zig uses, so
// the `FCard` values flow between them without a type mismatch.
const artifact = zit.artifact;
const git_import_cli = @import("git_import_cli.zig");

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

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

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

    // The ambient clock/environment values the runners need, read here — the
    // single place time and environment enter the CLI (design-seams rule) — and
    // threaded as plain values so every verb runner stays deterministic under
    // test. `$ZIT_PASSWORD` (sync §6.3) is a no-persistence password source for
    // `push`, read at this one seam like `$USER`.
    const amb: cli.Ambient = .{
        .now_epoch = commitEpoch(io, init.environ_map),
        .user_env = init.environ_map.get("USER") orelse init.environ_map.get("LOGNAME"),
        .push_password = init.environ_map.get("ZIT_PASSWORD"),
    };

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

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

/// Each verb's `--help` block, keyed by `cli.Verb` — the one place a verb is
/// paired with its help text. `zit help <verb>` and `zit <verb> --help` both
/// read this, so they teach identical text (DX D4). Verbs with no dedicated
/// help (`version`, `help`) and the `unknown` catch-all map to `null`; the
/// caller then falls back to `top_usage`. Keyed by the enum, so a new verb
/// forces a matching entry here at compile time — no help topic can silently
/// drop.
const verb_help = std.EnumArray(cli.Verb, ?[]const u8).init(.{
    .init = init_help,
    .add = add_help,
    .rm = rm_help,
    .mv = mv_help,
    .status = status_help,
    .stat = stat_help,
    .diff = diff_help,
    .extras = extras_help,
    .clean = clean_help,
    .addremove = addremove_help,
    .commit = commit_help,
    .log = log_help,
    .open = open_help,
    .update = update_help,
    .merge = merge_help,
    .revert = revert_help,
    .undo = undo_help,
    .redo = redo_help,
    .leaves = leaves_help,
    .timeline = timeline_help,
    .branch = branch_help,
    .tag = tag_help,
    .amend = amend_help,
    .info = info_help,
    .ls = ls_help,
    .cat = cat_help,
    .artifact = artifact_help,
    .annotate = annotate_help,
    .bisect = bisect_help,
    .serve = serve_help,
    .clone = clone_help,
    .pull = pull_help,
    .push = push_help,
    .sync = sync_help,
    .remote = remote_help,
    .pack = pack_help,
    .submanifest = submanifest_help,
    .import_git = import_git_help,
    .shun = shun_help,
    .verify = verify_help,
    .version = null,
    .help = null,
    .unknown = null,
});

/// The `--help` block for a verb named on the command line, or `null` when the
/// verb carries no dedicated help (or the name is unknown) — the caller then
/// falls back to `top_usage`. Resolves the name through `cli.verbOf` so help
/// topics track the dispatched verb set exactly.
fn helpFor(name: []const u8) ?[]const u8 {
    return verb_help.get(cli.verbOf(name));
}

/// 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,
    amb: cli.Ambient,
) cli.Exit {
    const verb = if (args.len >= 2) args[1] else "help";

    // `-R`/`--repo <path>` is a GLOBAL option: it is lifted out here, before the
    // verb's own parser runs, so ten read verbs share one implementation instead
    // of each re-declaring the flag. The verb then sees exactly the arguments it
    // has always seen, and the resolved store rides along in `Ambient`.
    // Bare `zit` carries no verb and therefore no verb arguments; `args[2..]`
    // would be out of bounds on it.
    const raw_args: []const []const u8 = if (args.len >= 2) args[2..] else &.{};
    const global = cli.parseGlobalArgs(arena, raw_args) catch
        return stateOom(streams, verb);
    const verb_args = switch (global) {
        .ok => |g| blk: {
            if (g.repo != null and !cli.acceptsRepoOverride(cli.verbOf(verb)))
                return cli.usageError(streams, verb, .repo_unsupported, "-R/--repo");
            break :blk g.args;
        },
        // A dangling `-R` is a dangling value option, phrased the way every
        // other value option in the CLI phrases one.
        .missing_value => |tok| return cli.usageError(streams, verb, .unknown_option, tok),
        .repeated_option => |tok| return cli.usageError(streams, verb, .repeated_option, tok),
        .empty_value => |tok| return cli.usageError(streams, verb, .empty_value, tok),
    };
    const amb_r: cli.Ambient = .{
        .now_epoch = amb.now_epoch,
        .user_env = amb.user_env,
        .push_password = amb.push_password,
        .repo_override = switch (global) {
            .ok => |g| g.repo,
            else => unreachable, // every other arm returned above
        },
    };

    switch (cli.verbOf(verb)) {
        .init => return runInit(gpa, io, arena, verb_args, streams),
        .add => return cmd_tracking.runAdd(gpa, io, arena, verb_args, streams),
        .rm => return cmd_tracking.runRm(gpa, io, arena, verb_args, streams),
        .mv => return cmd_tracking.runMv(gpa, io, arena, verb_args, streams),
        .status => return cmd_tracking.runStatus(gpa, io, arena, verb_args, streams),
        .stat => return cmd_history.runStat(gpa, io, arena, verb_args, streams),
        .diff => return cmd_tracking.runDiff(gpa, io, arena, verb_args, streams),
        .extras => return cmd_tracking.runExtras(gpa, io, arena, verb_args, streams),
        .clean => return cmd_tracking.runClean(gpa, io, arena, verb_args, streams),
        .addremove => return cmd_tracking.runAddremove(gpa, io, arena, verb_args, streams),
        .commit => return cmd_checkout.runCommit(gpa, io, arena, verb_args, streams, amb_r),
        .log => return cmd_history.runLog(gpa, io, arena, verb_args, streams, amb_r),
        .open => return cmd_sync.runOpen(gpa, io, arena, verb_args, streams),
        .update => return cmd_checkout.runUpdate(gpa, io, arena, verb_args, streams),
        .merge => return cmd_checkout.runMerge(gpa, io, arena, verb_args, streams),
        .revert => return cmd_checkout.runRevert(gpa, io, arena, verb_args, streams),
        .undo => return cmd_checkout.runUndo(gpa, io, arena, verb_args, streams),
        .redo => return cmd_checkout.runRedo(gpa, io, arena, verb_args, streams),
        .leaves => return cmd_history.runLeaves(gpa, io, arena, verb_args, streams, amb_r),
        .timeline => return cmd_history.runTimeline(gpa, io, arena, verb_args, streams, amb_r),
        .branch => return cmd_history.runBranch(gpa, io, arena, verb_args, streams, amb_r),
        .tag => return cmd_history.runTag(gpa, io, arena, verb_args, streams, amb_r),
        .amend => return cmd_checkout.runAmend(gpa, io, arena, verb_args, streams, amb_r),
        .info => return cmd_history.runInfo(gpa, io, arena, verb_args, streams, amb_r),
        .ls => return cmd_history.runLs(gpa, io, arena, verb_args, streams, amb_r),
        .cat => return cmd_history.runCat(gpa, io, arena, verb_args, streams, amb_r),
        .artifact => return cmd_history.runArtifact(gpa, io, arena, verb_args, streams, amb_r),
        .annotate => return cmd_history.runAnnotate(gpa, io, arena, verb_args, streams, amb_r),
        .bisect => return cmd_checkout.runBisect(gpa, io, arena, verb_args, streams),
        .serve => return cmd_sync.runServe(gpa, io, arena, verb_args, streams),
        .clone => return cmd_sync.runClone(gpa, io, arena, verb_args, streams),
        .import_git => return runImportGit(gpa, io, verb_args, streams),
        .pull => return cmd_sync.runPull(gpa, io, arena, verb_args, streams),
        .push => return cmd_sync.runPush(gpa, io, arena, verb_args, streams, amb_r),
        .sync => return cmd_sync.runSync(gpa, io, arena, verb_args, streams, amb_r),
        .remote => return cmd_sync.runRemote(gpa, io, arena, verb_args, streams),
        .pack => return cmd_sync.runPack(gpa, io, arena, verb_args, streams),
        .submanifest => return cmd_checkout.runSubmanifest(gpa, io, arena, verb_args, streams),
        .shun => return cmd_sync.runShun(gpa, io, arena, verb_args, streams, amb_r),
        .verify => return cmd_verify.runVerify(gpa, io, arena, verb_args, streams, amb_r),
        .version => {
            streams.out.writeAll("zit 0.1.3 (targets Fossil RFC rev " ++ zit.rfc_revision ++ ")\n") catch {};
            return .ok;
        },
        .help => {
            // `zit help <verb>` teaches that verb's own help — the same text as
            // `zit <verb> --help` (DX D4). Bare `zit`, `zit help`, and
            // `zit --help` list the commands. An unknown or help-less topic
            // (e.g. `version`) falls back to the command list, which now names
            // every real verb including `status`, so nothing reads as absent.
            if (args.len >= 3) {
                if (helpFor(args[2])) |topic_help| {
                    streams.out.writeAll(topic_help) catch {};
                    return .ok;
                }
            }
            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 import-git` — the option surface is `cli.import_git_usage`, named
/// rather than re-transcribed here because this comment was a fourth copy of
/// that line and had already fallen behind it.
///
/// Parse the command surface, split DEST into the parent handle and basename
/// the transaction owns, then run the production Git/import dependencies.
fn runImportGit(
    gpa: std.mem.Allocator,
    io: std.Io,
    verb_args: []const []const u8,
    streams: cli.Streams,
) cli.Exit {
    const parsed = cli.parseImportGitArgs(verb_args) catch |err| {
        // The conflict gets its own sentence: the generic form would print
        // "invalid arguments (ConflictingSubmanifestOptions)", which names the
        // cause without saying which two options collided or why.
        if (err == error.ConflictingSubmanifestOptions) {
            streams.err.writeAll("zit import-git: --no-submanifests and --submanifest-k cannot be combined; --submanifest-k tunes which k a tree import uses, it does not turn trees off\n") catch {};
            return .usage;
        }
        streams.err.print("zit import-git: invalid arguments ({s}); see 'zit import-git --help'\n", .{@errorName(err)}) catch {};
        return .usage;
    };
    if (parsed.help) {
        streams.out.writeAll(import_git_help) catch {};
        return .ok;
    }

    const parent_path = std.fs.path.dirname(parsed.destination) orelse ".";
    const destination_name = std.fs.path.basename(parsed.destination);
    var destination_parent = std.Io.Dir.cwd().openDir(io, parent_path, .{}) catch |err| {
        streams.err.print("zit import-git: cannot open destination parent '{s}': {s}\n", .{ parent_path, @errorName(err) }) catch {};
        return .failure;
    };
    defer destination_parent.close(io);

    var git = git_import_cli.ProductionGit.init(gpa, io);
    defer git.deinit();
    git.setDefaultBranch(parsed.default_branch);
    git.setRefuseSigned(parsed.refuse_signed);
    var dependencies = git.dependencies(.real_filesystem);
    if (parsed.max_export_payload_bytes) |bytes| dependencies.limits.max_export_payload_bytes = bytes;
    return git_import_cli.run(gpa, io, .{
        .source = parsed.source,
        .destination_parent = destination_parent,
        .destination_name = destination_name,
        // The parser has already made the pair unreachable, so the branch is
        // total: `no_submanifests` implies `submanifest_k == null`.
        .submanifest = if (parsed.no_submanifests) .flat else .{ .tree = parsed.submanifest_k },
        .attribution = switch (parsed.attribution) {
            .committer => .committer,
            .author => .author,
        },
        .default_branch = parsed.default_branch,
        .quiet = parsed.quiet,
    }, streams, dependencies);
}

/// `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.)
pub 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));
    };
    // §9.1 — give the repository a stable, unique project code (sync §9.1:
    // `projectcode = artifact-hash`). Derived deterministically from the
    // repo's absolute path — reproducible, distinct per repository, and with
    // no ambient RNG (the determinism seam). Persisted as repo config so
    // `serve` emits it in the clone reply's `push` card and a clone adopts it.
    const project_code = zit.sync.deriveProjectCode(&.{abs_zit});
    log.putConfig(.{
        .hash_policy = hash_kind,
        .default_branch = "trunk",
        .project_id = "",
        .project_code = &project_code,
    }) catch |e| {
        log.deinit(gpa);
        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", .merge_parent = "", .special_op = "", .manifest_flags = "" }) 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 one wording for "0.1.0 does not version symlinks", shared by explicit
/// `add`'s refusal and recursive `add`'s skip notice so the two can never drift
/// apart — the drift between the recursive and direct paths IS blocker B3
/// (docs/plans/CLI-VERB-BREAKDOWN.md §B3). A fifo/socket keeps the older,
/// separate "not a regular file" wording: those are not the same refusal and
/// must not read as one.
pub const symlink_unversioned = "symlink (not versioned in 0.1.0)";

/// The repository file's fixed name (logstore plan E: a copied `.zit` is a
/// complete repository).
pub const repo_filename = zit.repo_filename;

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

pub 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`).
pub 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 ----------------------------------------------------

/// Renders a checkout-state read failure for any verb; the verb prefixes the
/// message so one mapper serves both the read-only working-tree verbs and commit.
pub fn checkoutFail(s: cli.Streams, verb: []const u8, err: checkout.CheckoutError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.print(
            "zit {s}: cannot read checkout state: .zit-checkout is corrupt\n",
            .{verb},
        ) catch {},
        error.Storage => s.err.print(
            "zit {s}: cannot read checkout state: input/output error\n",
            .{verb},
        ) catch {},
    }
    return .failure;
}

/// Renders a managed-set read failure for any verb; the verb prefixes the
/// message so one mapper serves both the read-only working-tree verbs and commit.
pub fn managedFail(s: cli.Streams, verb: []const u8, err: managed.ManagedError) cli.Exit {
    switch (err) {
        error.Corrupt => s.err.print(
            "zit {s}: cannot read tracked files: .zit-managed is corrupt\n",
            .{verb},
        ) catch {},
        error.Storage => s.err.print(
            "zit {s}: cannot read tracked files: input/output error\n",
            .{verb},
        ) catch {},
        error.TooLong => s.err.print(
            "zit {s}: cannot read tracked files: a tracked path is too long\n",
            .{verb},
        ) catch {},
        error.TooMany => s.err.print("zit {s}: cannot read tracked files: too many tracked files\n", .{verb}) catch {},
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
    }
    return .failure;
}

pub fn stateOom(s: cli.Streams, verb: []const u8) cli.Exit {
    s.err.print("zit {s}: out of memory\n", .{verb}) catch {};
    return .failure;
}

// ==========================================================================
// `zit diff` — read-only metadata comparison of managed paths against the
// checkout's fully resolved F-set. It buffers every record before writing so
// an I/O/corruption/OOM failure never leaves a partial report on stdout.
// ==========================================================================

/// Upper bound on one managed working-tree file read by `commit` or `diff`.
/// The boundary is inclusive, so a file exactly this size is valid; larger
/// files are refused before allocation.
///
/// Defined in `managed` so the undo journal's pre-image capture shares the one
/// number; a layer holding a smaller one refuses files the others accepted.
pub const max_tracked_file_bytes: usize = managed.max_tracked_file_bytes;

comptime {
    // The working-tree ceiling IS the storage frame ceiling. A tracked file's
    // content becomes one artifact frame, and `logstore.put`/`putArtifact`
    // assert `bytes.len <= max_payload` — so if these ever diverged, commit
    // would trip that assert (undefined behaviour in a release build) instead of
    // refusing the file. Asserted here because this is the layer that imports
    // both; `managed` deliberately imports only `std`, so it cannot state it.
    std.debug.assert(max_tracked_file_bytes == logstore.max_payload);
}

pub fn trackedFileSizeAllowed(size: u64) bool {
    return size <= @as(u64, max_tracked_file_bytes);
}

pub fn executable(permissions: File.Permissions) bool {
    if (comptime File.Permissions.has_executable_bit) {
        return permissions.toMode() & 0o111 != 0;
    }
    return false;
}

/// The diagnostic every working-tree verb (add/rm/status) prints when discovery
/// finds no repository. Shared so all three fail identically (B-add-8 / B-rm-8 /
/// B-status-9 "identical shape") — a single, byte-identical line, exactly as each
/// contract quotes it.
pub const not_a_repo_msg =
    "zit: not a zit repository (no .zit found in this directory or any parent)\n";

// ==========================================================================
// `zit log CHECKIN` — one explicit check-in and its reachable parent graph.
// ==========================================================================

/// One honest phrase for a repository the read-only family cannot open or stat.
pub fn repoReadFail(s: cli.Streams, verb: []const u8) cli.Exit {
    s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {};
    return .failure;
}

/// Maps a `Repository.open` failure to the verb's diagnostic + exit 1. Closed
/// over `Repository.OpenError` so the CLI switch stays exhaustive; general over
/// the verb name so every read-only verb shares one map. The `not-a-repo` and
/// corrupt/IO phrasings are byte-identical to the pre-facade hand-wired opens.
pub fn repoOpenFail(s: cli.Streams, verb: []const u8, err: Repository.OpenError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {},
        error.NotARepo => s.err.writeAll(not_a_repo_msg) catch {},
        error.CannotRead => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {},
        error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {},
    }
    return .failure;
}

/// Discover the checkout root and open the repository through the `Repository`
/// facade for the read-only verb family (leaves, branch, tag, info). One place
/// owns the discover + open + per-verb diagnostics so every read-only verb reads
/// identical text. On `.ok`, `repo` is filled (caller `deinit`s it) and
/// `root_dir` is set (caller closes it AFTER `repo.deinit`); on any non-`.ok`
/// the diagnostic is already on stderr, `repo` is untouched, and any opened root
/// dir has been closed here.
pub fn openRepo(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    verb: []const u8,
    repo_override: ?[]const u8,
    bypass_cache: bool,
    fast_read: bool,
    repo: *Repository,
    root_dir: *std.Io.Dir,
) cli.Exit {
    // `resolveRoot` is the one place the `-R` route and the walk-up-from-cwd
    // route converge, so no verb below has to know which one it got.
    const root = (cli.resolveRoot(arena, io, repo_override) catch
        return stateOom(streams, verb)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    // Hot verbs take the zero-parse image path (`.fast_read`); the cold verbs
    // that read adjacency or the full tag tables stay on the full derive.
    repo.open(gpa, io, root.dir, .{
        .bypass_cache = bypass_cache,
        .mode = if (fast_read) .fast_read else .full,
    }) catch |e| {
        var d = root.dir;
        d.close(io);
        return repoOpenFail(streams, verb, e);
    };
    root_dir.* = root.dir;
    return .ok;
}

/// Displayed prefix of the full artifact hash on a timeline line. Display only:
/// the total order keys on the FULL hash inside `checkinsByMtime`, so a display
/// collision never perturbs ordering.
pub const timeline_hash_display = 12;

/// The first line of a check-in comment (up to, excluding, the first LF), so a
/// multi-line comment never injects a newline into the one-line-per-check-in
/// listing (B-timeline-6). A comment with no LF is returned whole.
pub fn firstCommentLine(comment: []const u8) []const u8 {
    const nl = std.mem.indexOfScalar(u8, comment, '\n') orelse return comment;
    return comment[0..nl];
}

/// Renders the §6.3 branch listing to stdout, one line per entry in `entries`
/// order (name-ascending, as `branches` returns them). `current` is the branch
/// name of the checked-out check-in (§6.1/§6.4) or null; the matching entry —
/// at most one, since §6.3 grouping yields distinct names — gets a leading
/// "* ", every other a two-space indent. A representative-closed branch (§6.3)
/// appends "  (closed)". Pure; bounded by `entries.len`.
pub fn renderBranchList(
    out: *std.Io.Writer,
    entries: []const repostate.RepoState.BranchEntry,
    current: ?[]const u8,
) void {
    var marked: usize = 0;
    for (entries) |entry| {
        const is_current = current != null and std.mem.eql(u8, entry.name, current.?);
        if (is_current) marked += 1;
        std.debug.assert(marked <= 1); // distinct names ⇒ at most one mark
        out.writeAll(if (is_current) "* " else "  ") catch {};
        out.writeAll(entry.name) catch {};
        if (entry.closed) out.writeAll("  (closed)") catch {};
        out.writeAll("\n") catch {};
    }
    std.debug.assert(marked <= 1);
}

/// The reserved 'sym-' prefix (repo-state §2): a symbolic name is stored with
/// it and surfaced with it stripped.
pub const sym_prefix = "sym-";

/// Ascending raw-byte order over two artifact IDs, so the merge-parent lines
/// print in a deterministic order (IDs are unique per check-in).
pub fn lessByHash(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

pub fn updateApplyFail(s: cli.Streams, target_id: []const u8, err: update.ApplyError) cli.Exit {
    switch (err) {
        error.OutOfMemory => s.err.writeAll("zit update: out of memory\n") catch {},
        error.InvalidCheckinId => s.err.print(
            "zit update: invalid check-in ID '{s}' (expected 40 or 64 lowercase hexadecimal digits)\n",
            .{target_id},
        ) catch {},
        error.CheckinNotFound => s.err.print("zit update: check-in '{s}' not found\n", .{target_id}) catch {},
        error.CheckinContentMissing => s.err.print(
            "zit update: cannot read repository: check-in '{s}' content is missing\n",
            .{target_id},
        ) catch {},
        error.CheckinNameMismatch => s.err.print(
            "zit update: cannot read repository: check-in '{s}' content does not match its name\n",
            .{target_id},
        ) catch {},
        error.TargetNotManifest => s.err.print("zit update: check-in '{s}' is not a valid manifest\n", .{target_id}) catch {},
        error.BaselineNotFound => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline is not found\n",
            .{target_id},
        ) catch {},
        error.BaselineContentMissing => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline content is missing\n",
            .{target_id},
        ) catch {},
        error.BaselineNameMismatch => s.err.print(
            "zit update: cannot read repository: check-in '{s}' baseline content does not match its name\n",
            .{target_id},
        ) catch {},
        error.BaselineNotManifest => s.err.print(
            "zit update: check-in '{s}' baseline is not a valid manifest\n",
            .{target_id},
        ) catch {},
        error.BaselineIsDelta => s.err.print(
            "zit update: check-in '{s}' baseline is not fully resolvable\n",
            .{target_id},
        ) catch {},
        error.StateRecordInvalid => s.err.writeAll("zit update: cannot read repository: " ++ update.state_record_invalid_reason ++ "\n") catch {},
        error.Corrupt => s.err.writeAll("zit update: cannot read repository: repository is corrupt\n") catch {},
        error.InvalidPath => s.err.print("zit update: check-in '{s}' names an invalid file path\n", .{target_id}) catch {},
        error.ReservedPath => s.err.print("zit update: check-in '{s}' names a reserved file path\n", .{target_id}) catch {},
        error.SymlinkUnsupported => s.err.print(
            "zit update: check-in '{s}' contains an unsupported symlink entry\n",
            .{target_id},
        ) catch {},
        error.CurrentStateInvalid => s.err.writeAll("zit update: current working-tree state is invalid\n") catch {},
        error.WorktreeNotClean => s.err.writeAll("zit update: working tree is not clean\n") catch {},
        error.UnmanagedCollision => s.err.writeAll("zit update: an untracked file or directory is in the way\n") catch {},
        error.Storage => s.err.writeAll("zit update: cannot update working tree: input/output error\n") catch {},
        error.TransactionUnavailable => s.err.writeAll("zit update: cannot create a transaction directory\n") catch {},
        error.RollbackFailed => s.err.writeAll(
            "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
        ) catch {},
        error.CleanupFailed => s.err.writeAll(
            "zit update: update committed, but the transaction directory could not be removed\n",
        ) catch {},
    }
    return .failure;
}

// ==========================================================================
// `zit commit` — mints a check-in (contracts/commit.md B-commit-1..20). The
// first verb that appends to the log AND advances the working state. A thin
// consumer: it decides which files at which content, hands the library RFC card
// structs, and the library owns all format work (`artifact.create` computes the
// Z self-check, orders/escapes cards, frames the artifact). No logic enters any
// library module; no `std.http`, no network; history is append-only. The one
// deep, RFC-normative unit — the F-set composition — is `cli.composeFCards`,
// pure and separately unit-tested; `runCommit` is thin wiring around it.
// ==========================================================================

/// SHA3-256 digest length: the on-disk log frame is keyed by these 32 raw bytes
/// (logstore.max_hash_len), while the artifact NAME is their 64-hex form. `init`
/// wrote the SHA3-256 policy (B-commit-16), so the algorithm is this constant.
pub const digest_len = 32;

/// Decodes an artifact name into its binary digest — the log-frame key — writing
/// into `out` and returning the used prefix. Returns null for anything that is
/// not a name this suite mints, so the caller maps it to a diagnostic rather
/// than trapping.
///
/// LENGTH IS DERIVED FROM THE NAME, NOT ASSUMED. [FossilArtifact] §7.2 admits
/// two widths — 20 bytes (sha1) and 32 bytes (sha3-256) — and a repository is
/// free to hold either. This used to require exactly `digest_len * 2` hex
/// characters because `zit init` writes the sha3-256 policy, which made the
/// sha3 case look like the only case. It is not: `zit import-git` opens its
/// destination under git's native sha1, so every check-in in an imported
/// repository is named in 40 hex characters. The old check rejected all of
/// them, and `zit commit` reported the parent check-in as corrupt on a store
/// `zit verify` called clean (issue #24).
///
/// The buffer is `digest_len` wide because that is the larger of the two; a
/// 20-byte digest uses the prefix. Callers pass the returned SLICE onward —
/// passing `&out` would reintroduce the bug with the padding included.
pub fn hexToDigest(hex: []const u8, out: *[digest_len]u8) ?[]const u8 {
    if (hex.len != 40 and hex.len != digest_len * 2) return null;
    const n = hex.len / 2;
    _ = std.fmt.hexToBytes(out[0..n], hex) catch return null;
    return out[0..n];
}

/// Lowercase-hex-encodes a raw binary `digest` into `out`, returning the used
/// prefix — the log-frame key's wire NAME (sync §3.3). The inverse of
/// `decodeWireName`; both live at the log↔store boundary so no other module
/// sees a raw digest cross into wire space.
pub fn digestToWireName(digest: []const u8, out: *[2 * digest_len]u8) []const u8 {
    std.debug.assert(digest.len <= digest_len);
    const hexdigits = "0123456789abcdef";
    for (digest, 0..) |b, i| {
        out[2 * i] = hexdigits[b >> 4];
        out[2 * i + 1] = hexdigits[b & 0x0f];
    }
    return out[0 .. 2 * digest.len];
}

/// Decodes a wire artifact NAME (40- or 64-hex, sync §3.3) into its raw binary
/// digest — the log-frame key. Returns null for anything that is not an
/// even-length hex string of at most `digest_len` bytes, so a malformed peer
/// name is rejected cleanly rather than trapping. The inverse of
/// `digestToWireName`.
pub fn decodeWireName(name: []const u8, out: *[digest_len]u8) ?[]const u8 {
    if (name.len == 0 or name.len % 2 != 0) return null;
    const nbytes = name.len / 2;
    if (nbytes > digest_len) return null;
    _ = std.fmt.hexToBytes(out[0..nbytes], name) catch return null;
    return out[0..nbytes];
}

/// Reads the whole-second wall clock for the D card (B-commit-7), preferring the
/// `SOURCE_DATE_EPOCH` reproducible-build convention when set (a well-known,
/// tell-free way to pin the clock across a process boundary — how the e2e suite
/// fixes the timestamp of the spawned binary). Falls back to the real
/// (wall-clock) `Io.Clock`. The single ambient-time read in the CLI.
fn commitEpoch(io: std.Io, env: *const std.process.Environ.Map) i64 {
    if (env.get("SOURCE_DATE_EPOCH")) |raw| {
        const trimmed = std.mem.trim(u8, raw, " \t\r\n");
        if (std.fmt.parseInt(i64, trimmed, 10)) |v| return v else |_| {}
    }
    return Io.Timestamp.now(io, .real).toSeconds();
}

/// `zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME]`
/// (contracts/amend.md B-amend-1..17). amend NEVER rewrites a check-in: it mints
/// ONE control artifact whose T cards change how the target check-in DISPLAYS
/// (comment/user/date/branch), which the crosslinker applies on the next read
/// (repo-state §4/§5). History stays append-only — the original manifest is
/// untouched (B-amend-5). The one durable write is the control artifact + log
/// fsync; every validation (parse, NAME resolution, `--date` predicate,
/// target-is-a-check-in) precedes it, so a refusal mints nothing.
/// Assemble the ONE control artifact carrying `tags`, serialize it (artifact
/// §5.2: exactly D, T…, U, Z), and store it durably in the log — the single
/// fsync. Returns its 64-hex artifact name, written into `name_buf`.
///
/// This is the mint step shared by `amend` and `tag add`: both compose a T run
/// over a resolved check-in and then need exactly this. Everything a caller does
/// before calling is read-only or in-memory, so a crash before the call mints
/// nothing; after it returns, the control is durable and crosslinks on the next
/// read (B-amend-5/7). The library owns card order, escaping, the Z self-check,
/// and framing. D is the mint time — the §5.3 supersede clock (B-amend-6); U is
/// the author's env login (empty → `anonymous` on read, §4.7.17).
///
/// The error set is `logstore.OpenError`, the widest of the three the body can
/// produce, so one caller-side mapper renders all of them under its own verb.
pub fn mintControlArtifact(
    gpa: std.mem.Allocator,
    io: std.Io,
    root_dir: std.Io.Dir,
    amb: cli.Ambient,
    tags: []const artifact.TCard,
    name_buf: *[64]u8,
) logstore.OpenError![]const u8 {
    std.debug.assert(tags.len >= 1); // a control artifact with no T run says nothing
    var ts_buf: [19]u8 = undefined;
    const ts = cli.formatWriterTimestamp(amb.now_epoch, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = amb.user_env orelse "" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = tags,
    };
    defer m.deinit(); // frees the empty arena_state; card storage is the caller's
    const bytes = try artifact.create(&m, gpa);
    defer gpa.free(bytes);
    // Correctness self-check (safety builds only): the emitted bytes round-trip
    // to a control artifact. A failure means the CLI mis-populated a card — a bug
    // caught here, never shipped (mirrors commit's self-check).
    if (std.debug.runtime_safety) {
        var check = artifact.parse(gpa, bytes) catch |e|
            std.debug.panic("the control mint produced an unparseable artifact: {s}", .{@errorName(e)});
        defer check.deinit();
        std.debug.assert(check.kind == .control);
    }

    var file = root_dir.openFile(io, repo_filename, .{ .mode = .read_write }) catch
        return error.Storage;
    defer file.close(io);
    const log_st = file.stat(io) catch return error.Storage;
    var fs = storage.FileStorage.init(io, file, log_st.size);
    var log = try logstore.LogStore.open(gpa, fs.storageFile(), 0);
    defer log.deinit(gpa);

    var cdigest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &cdigest, .{});
    const control_name = artifact.artifactName(.sha3_256, bytes, name_buf);
    _ = try log.putArtifact(&cdigest, bytes, null);
    try log.commit();
    return control_name;
}

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)
    \\  mv         rename a tracked file
    \\  status     show the working checkout state
    \\  stat       report the repository store's size and artifact census
    \\  diff       compare managed files with the current check-in
    \\  extras     list files in the checkout that zit isn't tracking
    \\  clean      delete files in the checkout that zit isn't tracking
    \\  addremove  track new files and forget deleted ones in one pass
    \\  commit     record a check-in from your tracked files
    \\  log        show a check-in and its reachable parents
    \\  open       fill a freshly cloned directory with its files
    \\  update     materialize a check-in in the working tree
    \\  merge      merge another check-in into the working tree
    \\  revert     restore managed files to their checked-in contents
    \\  undo       undo the last update or revert
    \\  redo       reapply an undone change
    \\  leaves     list the open leaves of the check-in DAG
    \\  timeline   list every check-in, most recent first
    \\  branch     list the repository's branches
    \\  tag        list the tags in use, or give a check-in a name
    \\  amend      attach display overrides to a check-in
    \\  info       resolve a name to a check-in and describe it
    \\  ls         list the files of a check-in
    \\  cat        emit a file's contents at a check-in
    \\  artifact   print or describe a raw artifact by its hash
    \\  annotate   show which check-in introduced each line of a file
    \\  bisect     binary-search the check-in DAG for a good/bad boundary
    \\  serve      serve this repository over HTTP for sync
    \\  clone      copy a repository from a URL into a new directory
    \\  pull       fetch new artifacts from a URL into this repository
    \\  push       send this repository's artifacts to a URL
    \\  sync       reconcile with a peer in both directions
    \\  remote     show, set, or clear the default sync remote
    \\  pack       repack the repository store into a compact new generation
    \\  submanifest  show or change this repository's sub-manifest settings
    \\  import-git  import a Git repository into a new zit repository
    \\  shun       permanently remove artifacts and record them as never-fetch
    \\  verify     check this repository's store for damage
    \\  version    print the version
    \\  help       show this help
    \\
;
const import_git_help = "usage: " ++ cli.import_git_usage ++ "\n" ++
    \\
    \\Import Git repository SOURCE into fresh destination DEST. The import is
    \\transactional: DEST appears only after Git export, artifact creation, and
    \\Git ref publication complete successfully.
    \\
    \\Branches are reconstructed from the commit graph, not from the ref each
    \\commit was exported under: 'git fast-export' emits every commit exactly
    \\once, under whichever ref reached it first, and Git itself has no
    \\branch-of-a-commit to ask. The destination's main branch claims its whole
    \\first-parent chain down to the root; every other branch then claims,
    \\shallowest tip first with ties broken by name, from its own tip down
    \\first parents until it reaches a commit already claimed. So a merge stays
    \\on the branch it was merged into, and history below a fork stays with the
    \\branch that continues through it. Only the deepest check-in of a claimed
    \\run records its branch — the rest inherit it, exactly as they would in a
    \\repository zit had written itself. Two branches on the same commit are
    \\the one degenerate case: there is a single run, the first name
    \\alphabetically records it, and the other still resolves as a name but is
    \\not listed by 'zit branch'.
    \\
    \\A destination imported by an earlier zit took each check-in's branch from
    \\that exporting ref instead, which scrambled branch membership and
    \\inflated 'zit leaves'. Only the derived branch state was wrong: files,
    \\check-ins, and parent links were correct, and a re-import writes
    \\byte-identical check-in manifests, so recorded check-in hashes survive
    \\it. Re-import such a destination from its Git source to repair it; one
    \\that has been committed to with zit since its import cannot be repaired
    \\that way.
    \\
    \\options:
    \\  --submanifest-k K
    \\                pin K (1 through 12) instead of analyzing. By default the
    \\                import measures the source's own file names and pins the
    \\                sub-manifest optimum; override only to match an existing
    \\                repository's pin, which is what lets two repositories
    \\                share sub-manifests
    \\  --no-submanifests
    \\                import with sub-manifests off: every check-in gets a flat
    \\                manifest and the destination keeps the default settings
    \\                (enable-submanifests off, no pinned k). Flat is the
    \\                default shape for a new repository; it is this verb that
    \\                turns sub-manifests on. Cannot be combined with
    \\                --submanifest-k, which chooses which k a tree import
    \\                uses rather than whether one is used
    \\  --attribution committer|author
    \\                record Git committer identities (default) or author identities
    \\  --default-branch NAME
    \\                make NAME the destination's main branch instead of
    \\                choosing it from the source (refs/heads/main when the
    \\                source has it, otherwise its first head). NAME must be a
    \\                branch the source publishes, or the import is refused.
    \\                This is not only a label: the main branch claims its
    \\                first-parent chain before any other, so NAME also decides
    \\                which branch keeps the history below every fork
    \\  --refuse-signed
    \\                refuse a source whose commits or tags are signed instead
    \\                of importing it. By default a signature is STRIPPED and
    \\                the import proceeds: Fossil has no signature card, so no
    \\                import can carry one through, and refusing withheld the
    \\                repository without saving the signature. Nothing else
    \\                about a commit changes either way
    \\  --max-export-payload-bytes BYTES
    \\                cumulative Git payload this import may read
    \\                (default 1099511627776). It is a bound on total
    \\                history, not on memory: payload is streamed and
    \\                released per event. Raise it when an import
    \\                reports ExportPayloadLimitExceeded
    \\  --quiet       suppress progress diagnostics
    \\  -h, --help    show this help and exit
    \\
;

pub const log_help =
    \\usage: zit log [-n N] [-R <path>] CHECKIN [PATH]
    \\
    \\Show CHECKIN followed by every reachable parent check-in in depth-first
    \\P-card order. CHECKIN must be an exact 40- or 64-character lowercase
    \\hexadecimal artifact ID. Each check-in is shown once; a referenced parent
    \\missing from this repository is shown once as a phantom.
    \\
    \\With PATH, show only the reachable check-ins that change PATH — where
    \\PATH's effective content differs from its primary parent's, including
    \\where PATH is added or deleted. PATH is an exact repo-relative path, not
    \\a pattern; a path that never changed yields no output.
    \\
    \\options:
    \\  -n, --limit N show only the N most recent entries; 0 means all
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help    show this help and exit
    \\
;
pub const open_help =
    \\usage: zit open [--manifest[=r,u,t]] [-h|--help]
    \\
    \\Materialize the tip of this repository's main branch in the working tree.
    \\This is the second half of 'zit clone URL DIR': clone builds DIR and its
    \\'.zit', open fills DIR with the files of the newest check-in. Run it from
    \\inside the checkout directory.
    \\
    \\A working tree that is already open at a check-in is left untouched; use
    \\'zit update' to move an open tree to a different check-in. A repository
    \\with no check-in yet is already in its correct (empty) state, which open
    \\reports and leaves alone.
    \\
    \\--manifest asks this checkout for the files some build systems read to
    \\stamp a version into their output: 'manifest' (the check-in artifact, r),
    \\'manifest.uuid' (its hash, u) and 'manifest.tags' (its symbolic tags, t).
    \\The bare flag means 'ru'. The choice is remembered, so every later
    \\'zit update' rewrites the files for the check-in it moves to. Without the
    \\flag none are written, and a file of one of those names that the check-in
    \\itself tracks is never replaced.
    \\
    \\options:
    \\  --manifest[=r,u,t]  write manifest, manifest.uuid, manifest.tags
    \\  -h, --help          show this help and exit
    \\
;

pub const update_help =
    \\usage: zit update CHECKIN
    \\
    \\Materialize CHECKIN in the working tree. CHECKIN resolves like 'zit info'
    \\— a full or abbreviated artifact ID, a branch, or a tag.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

pub const merge_help =
    \\usage: zit merge NAME
    \\       zit merge --cherrypick NAME
    \\       zit merge --backout NAME
    \\
    \\Merge the check-in NAME into the working tree with a three-way merge and
    \\record it as a pending merge parent. NAME resolves like 'zit info' — a full
    \\or abbreviated artifact ID, a branch, or a tag. This does not create a
    \\check-in; run 'zit commit' to record the merge. Conflicts are written into
    \\the working files with conflict markers and listed on stderr — resolve them
    \\and commit, or run 'zit undo' to abandon the merge.
    \\
    \\--cherrypick and --backout are special merges that pick an alternate
    \\baseline instead of the common ancestor: --cherrypick applies only NAME's
    \\own change onto the working tree, --backout reverses it. 'zit commit' then
    \\records the operation as a cherry-pick/backout, not as a merge parent.
    \\
    \\options:
    \\  -h, --help          show this help and exit
    \\  --cherrypick NAME   apply only NAME's own change onto the working tree
    \\  --backout NAME      reverse NAME's change on the working tree
    \\
;

pub const bisect_help =
    \\usage: zit bisect bad NAME | good NAME | next | status | reset
    \\
    \\Binary-search the check-in DAG for the check-in that introduced a defect.
    \\Mark a known-bad and a known-good check-in; zit narrows the candidate span
    \\to the ancestors of the bad check-in that are not ancestors of any good one
    \\and moves the working tree to the midpoint for you to test. Repeat until one
    \\check-in remains — the first bad check-in.
    \\
    \\subcommands:
    \\  bad NAME    mark NAME as containing the defect
    \\  good NAME   mark NAME as free of the defect
    \\  next        move to (or re-announce) the current probe check-in
    \\  status      show the marks, the current probe, and the remaining count
    \\  reset       end the bisection and restore the starting check-in
    \\
    \\NAME resolves like 'zit info' — a full or abbreviated artifact ID, a branch,
    \\or a tag. Marking moves the working tree through the update engine, so a move
    \\is refused over a dirty working tree and is recoverable with 'zit undo'. The
    \\bisection state lives in .zit-bisect and never enters the repository.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

pub const annotate_help =
    \\usage: zit annotate PATH
    \\
    \\Show, for each line of PATH at the current check-out, the check-in that
    \\introduced that line and when. PATH is an exact repo-relative path to a
    \\tracked file; each output line is the introducing check-in's 10-character
    \\artifact-ID prefix, the ISO date it was recorded (UTC), then the file line.
    \\
    \\Provenance is computed over the file's whole version history: a line is
    \\attributed to the oldest check-in that carries it. A line that arrived from
    \\a merged branch is credited to its origin on that branch, never to the merge
    \\check-in itself. Only committed content is annotated — uncommitted edits in
    \\the working file are not shown.
    \\
    \\options:
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the annotation was written
    \\  1  PATH is not tracked at the current check-out, its content is binary,
    \\     or this is not a zit repository
    \\  2  usage error (a missing PATH, an unknown option, or a second positional)
    \\
;

pub const revert_help =
    \\usage: zit revert [PATH...]
    \\
    \\Restore managed files to their contents at the current check-in, discarding
    \\working-tree edits. With no PATH, every managed file is reverted; name one
    \\or more paths to revert just those.
    \\
    \\revert changes file contents only — it never changes which files are
    \\tracked. A managed file that the current check-in does not contain (added
    \\but never committed, or deleted at this check-in) is reported and left
    \\alone, never recreated.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the named files were reverted (or had nothing to restore)
    \\  1  a path is not tracked, a file could not be written, the checkout is
    \\     unborn, or this is not a zit repository
    \\  2  usage error (an unknown option)
    \\
;

pub const undo_help =
    \\usage: zit undo [-h|--help]
    \\
    \\Undo the last working-file change made by 'zit update' or 'zit revert',
    \\restoring every affected file and the checkout record to their state before
    \\that change. Single level: only the most recent change can be undone; a second
    \\'zit undo' in a row has nothing left to undo.
    \\
    \\Use 'zit redo' to reapply a change you just undid. A new update or revert
    \\clears the redo slot.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the change was undone
    \\  1  nothing to undo, the journal is damaged, or this is not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

pub const redo_help =
    \\usage: zit redo [-h|--help]
    \\
    \\Reapply the change you just undid with 'zit undo', restoring every affected
    \\file and the checkout record to their state after that change. Single level:
    \\redo is available only immediately after an undo; a new update or revert
    \\clears it.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the change was reapplied
    \\  1  nothing to redo, the journal is damaged, or this is not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

pub const pack_help =
    \\usage: zit pack [-h|--help]
    \\
    \\Repack this repository's store into a compact new generation, re-encoding
    \\full-form artifacts as deltas against their lineage (a check-in against its
    \\parent, a file against the same path in the parent check-in) and dropping
    \\superseded config records. Apart from shunned content (below), every
    \\artifact's content and name are preserved exactly; the on-disk layout is the
    \\only thing that changes.
    \\
    \\pack is also where shunned content is RECLAIMED. A shun only masks an
    \\artifact from being read or served; its bytes stay in the store until a pack
    \\rewrites the store without them. A run that removed something says how many
    \\in its summary line. The tombstones themselves are kept, so a reclaimed
    \\artifact still reads as absent and syncing will not fetch it back.
    \\Reclamation is not reversible: once packed, the bytes are gone. Reclaiming an
    \\artifact that other artifacts were stored as deltas against can leave the
    \\store slightly LARGER, since its content has to move into one of them.
    \\
    \\pack streams the whole store, verifying every frame's checksum and that each
    \\reconstructed artifact still hashes to its name — so it doubles as a
    \\whole-store integrity check. Reclaimed artifacts are never reconstructed, so
    \\a shunned artifact whose content no longer matches its name does not block
    \\the pack that removes it. A frame whose CHECKSUM is damaged still does: the
    \\checksum pre-flight covers the whole store and runs before any artifact is
    \\reclaimed. The packed log is written to a temporary file and swapped into
    \\place atomically: a crash before the swap leaves the original untouched. It
    \\transiently needs room for both the old and new store.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

pub const verify_help =
    \\usage: zit verify [-R <path>] [-h|--help]
    \\
    \\Check this repository's store for damage. verify is zit's own fsck and a
    \\local design decision, not a verb any specification requires: repo-state
    \\§7.3 blesses rebuilding a repository from its artifacts as a recovery
    \\fallback, but it says nothing about how a check should look, and nothing
    \\here claims otherwise.
    \\
    \\It makes two passes. The first reads every frame in the store and checks
    \\its whole-frame checksum, which catches a bitflip in bytes already on disk.
    \\The second reconstructs every stored artifact through its delta chain and
    \\re-hashes it against its own name, which catches a frame whose checksum is
    \\perfectly valid over the wrong bytes. Each finding names what is damaged
    \\and where, and the summary says how much of the store was actually read.
    \\
    \\verify is read-only. It takes no lock, writes nothing, and never truncates,
    \\so it is safe to run against a repository a `zit serve` has open.
    \\
    \\Four things it deliberately does not tell you. Config records and shun rows
    \\are checksum-checked but not structurally verified, because they are never
    \\interned as artifacts and the per-artifact pass cannot see them. A manifest
    \\that hashes correctly but is semantically malformed is a zit bug, not
    \\storage damage, and is out of scope. Derived caches are not checked; a
    \\stale cache is not damage. And the walk covers the store as it was when
    \\verify started, so frames a concurrent `zit commit` appends while it runs
    \\are not examined.
    \\
    \\An incomplete final frame — the ordinary result of a write interrupted by a
    \\crash — is reported but is not a fault: the next write truncates it away.
    \\
    \\Shunned artifacts whose bytes have not yet been reclaimed ARE verified and
    \\reported, and are labelled as such. This differs from `zit pack`, which
    \\filters them out before verifying so that damage cannot block its own
    \\removal; verify has no removal to block, and must not report clean about
    \\bytes it declined to look at.
    \\
    \\options:
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  no fault was found (an incomplete final frame from an interrupted write
    \\     is reported but is not a fault)
    \\  1  a fault was found, the store could not be read, or not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

pub const shun_help =
    \\usage: zit shun [--reason TEXT] [--defer] [--force] HASH...
    \\       zit shun --list
    \\
    \\Permanently remove one or more artifacts from this repository and record
    \\them so they are never fetched again. This is irreversible: `zit undo` does
    \\not recover a shun, and the content cannot be re-fetched from a peer.
    \\
    \\By default shun refuses and removes nothing: it reports exactly what would
    \\be destroyed and stops. Pass --force to proceed. shun never prompts.
    \\
    \\Each hash is recorded in this repository's shun table, and every sync
    \\direction honours it: the artifact is never requested from a peer, never
    \\announced or served to one, and an arriving copy is discarded rather than
    \\stored. A peer that still holds it can keep offering it; a later sync will not
    \\land it back in this store. The table is local to this repository: a peer that
    \\has not shunned the artifact keeps it, and zit does not propagate the table.
    \\
    \\A hash this repository does not hold may still be shunned; the entry is
    \\recorded and blocks a future fetch, and no rewrite runs because there is
    \\nothing to reclaim. Give such a hash in full: an abbreviated prefix that
    \\matches nothing here is refused, because the table is keyed by complete
    \\artifact name and a prefix cannot be completed.
    \\
    \\A hash whose row already exists but whose bytes are still on disk — anything
    \\shunned earlier with --defer — is reclaimed by --force, not reported as a
    \\no-op. Only a hash that is both recorded and gone is nothing to do.
    \\
    \\The check-in your working tree is currently on cannot be shunned, with or
    \\without --force; run `zit update` to a different check-in first.
    \\
    \\Removing the bytes rewrites the whole store into a new generation and swaps
    \\it in atomically, exactly as `zit pack` does; a crash before the swap leaves
    \\the original untouched, with no tombstone recorded. It transiently needs
    \\room for both the old and new store, and it also reclaims any shun that was
    \\deferred earlier.
    \\
    \\Pass --defer to record the shun without rewriting. Reads answer identically
    \\either way, but --defer does NOT make the content unreachable: until
    \\`zit pack` runs, the bytes remain on disk and stay reconstructible through
    \\any delta stored against them, because the mask is on the name rather than
    \\the bytes. Only the rewrite removes them. --defer still needs --force: the
    \\tombstone it appends is just as irreversible.
    \\
    \\A `zit serve` already running when a --defer shun lands picks it up on its
    \\next request, with no restart: the row is appended to the store it already
    \\has open. A --force shun that reclaims bytes rewrites the store into a NEW
    \\file, which a running serve does not see — it keeps serving the old one from
    \\its open handle until it is restarted. So a takedown on a live server is
    \\safest as --defer
    \\first, with --force (or `zit pack`) reclaiming the bytes afterwards.
    \\
    \\Only one repository rewrite runs at a time: if a `zit pack` or another
    \\`zit shun --force` is already rewriting this store, this one is refused
    \\rather than allowed to collide with it. A --defer run is refused the same
    \\way, because a row appended while another process was swapping in a new
    \\generation would be lost with the old one. Nothing is recorded when that
    \\happens.
    \\
    \\A shun is also refused if the store changed between the moment it was
    \\examined and the moment it would be written — a `zit commit` landing one of
    \\the named hashes, say. The report would otherwise describe a repository that
    \\no longer exists. Nothing is recorded; run it again.
    \\
    \\--reason must be a single line: a newline in the note would forge a row in
    \\--list, the only view of a record that cannot be removed.
    \\
    \\options:
    \\      --reason TEXT  record a note with the shun (shown by --list)
    \\      --defer        record the shun; leave the bytes until `zit pack`
    \\      --force        actually record and remove
    \\      --list         list this repository's shun table and exit
    \\  -h, --help         show this help and exit
    \\
    \\exit status:
    \\  0  the shun table was listed, every hash was shunned, or every hash was
    \\     already shunned AND already gone (a no-op needs no --force)
    \\  1  refused (no --force), a hash could not be shunned, another rewrite is in
    \\     progress, the repository changed while it was being examined, or not a
    \\     zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

pub const leaves_help =
    \\usage: zit leaves [--all] [--no-cache] [-h|--help]
    \\
    \\List the open leaves of the check-in DAG — check-ins with no child on the
    \\same branch that carry no active closed tag. Each line shows the check-in's
    \\full artifact ID and its branch. Leaves are ordered most-recent first; the
    \\leaf matching the current checkout is marked with a leading "*".
    \\
    \\options:
    \\      --all       also list closed leaves, each marked "(closed)"
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const timeline_help =
    \\usage: zit timeline [-n N] [-R <path>] [-h|--help]
    \\
    \\List every check-in in the repository, most recent first. Each line shows
    \\the check-in's short hash, its ISO timestamp, its branch, and the first
    \\line of its comment. A check-in that joins two or more lines of development
    \\is marked "M". Order is by event time descending, ties broken by artifact
    \\hash, so the listing is byte-identical across runs.
    \\
    \\options:
    \\  -n, --limit N show only the N most recent entries; 0 means all
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help    show this help and exit
    \\
;

pub const branch_help =
    \\usage: zit branch [--no-cache] [-h|--help]
    \\
    \\List the repository's branches, one per line, ordered by name. Branches are
    \\derived from the active branch tags of the check-ins; the branch of the current
    \\checkout is marked with a leading "*". A branch whose most recent check-in is
    \\closed is marked "(closed)".
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const tag_help =
    \\usage: zit tag list [CHECKIN] [--no-cache]
    \\       zit tag add NAME CHECKIN
    \\
    \\List tags, or give a check-in a name. With no CHECKIN, list prints the tag
    \\names in use across the repository, one per line, sorted. With a CHECKIN,
    \\it prints the tags visible on that check-in and their values.
    \\
    \\add names one check-in: NAME then resolves to it everywhere a check-in
    \\name is accepted. The name stays on that check-in — unlike a branch name,
    \\it is not inherited by the check-ins committed after it.
    \\
    \\  ls          alias for list
    \\
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help  show this help
    \\
;

pub const serve_help =
    \\usage: zit serve [--port N] [-h|--help]
    \\
    \\Serve this repository over HTTP so a peer can sync against it. Answers a
    \\POST to /xfer carrying an application/x-fossil body per the sync protocol,
    \\one request per connection, single-threaded. Binds 127.0.0.1 on port N
    \\(default 8080). Stop it with Ctrl-C.
    \\
    \\options:
    \\      --port N  listen on port N (default 8080)
    \\  -h, --help    show this help and exit
    \\
;

pub const clone_help =
    \\usage: zit clone URL DIR [-h|--help]
    \\
    \\Copy a repository served at URL into a new directory DIR. Creates DIR (and
    \\its `.zit`), opens an HTTP connection to URL, syncs the repository's
    \\artifacts down to convergence, and stores them so DIR is a usable repo.
    \\
    \\URL is the peer's sync endpoint — an http:// or https:// address, used
    \\verbatim (the path is not rewritten). The clone is anonymous: no login.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;
pub const pull_help =
    \\usage: zit pull [URL] [-h|--help]
    \\
    \\Fetch artifacts from the repository served at URL into the current repository.
    \\Opens an HTTP connection to URL, announces what this repository already holds,
    \\requests everything it lacks, and stores the newly received artifacts so this
    \\repository converges toward the peer's content.
    \\
    \\URL is the peer's sync endpoint — an http:// or https:// address, used
    \\verbatim (the path is not rewritten). The pull is anonymous: no login. With
    \\URL omitted the saved remote is used ('zit remote URL' saves one); a repo
    \\with no saved remote and no URL is an error.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;
pub const push_help =
    \\usage: zit push [URL] [--user U [--password P]] [-h|--help]
    \\
    \\Send this repository's artifacts to the repository served at URL. Opens an
    \\HTTP connection to URL, announces what this repository holds, and uploads
    \\every artifact the peer lacks until both sides converge. The peer stores the
    \\uploaded artifacts; nothing on this side changes.
    \\
    \\URL is the peer's sync endpoint — an http:// or https:// address, used
    \\verbatim (the path is not rewritten). With URL omitted the saved remote is
    \\used ('zit remote URL' saves one); a repo with neither is an error.
    \\
    \\Without --user the push is anonymous (no login card). With --user the push
    \\authenticates: the shared secret is derived from the password and the login
    \\card is signed with it. The password comes from --password, else the
    \\ZIT_PASSWORD environment variable, else a no-echo prompt. After a successful
    \\authenticated push the derived secret (never the password) is saved in the
    \\repository at 0600, so a later push with just --user needs no password.
    \\
    \\options:
    \\  --user U        authenticate as login U (sync §6)
    \\  --password P    U's password (prompted without echo if omitted)
    \\  -h, --help      show this help and exit
    \\
;
pub const sync_help =
    \\usage: zit sync [URL] [--user U [--password P]] [-h|--help]
    \\
    \\Reconcile this repository with the one served at URL in BOTH directions in a
    \\single exchange: this side uploads every artifact the peer lacks and receives
    \\every artifact it lacks, until both converge. This is one session, not a pull
    \\followed by a push — sync history transfers both ways and never discards
    \\artifacts (so there is no force-push).
    \\
    \\URL is the peer's sync endpoint — an http:// or https:// address, used
    \\verbatim (the path is not rewritten). With URL omitted the saved remote is
    \\used ('zit remote URL' saves one); a repo with neither is an error.
    \\
    \\Without --user the sync is anonymous. With --user it authenticates for the
    \\upload exactly as 'zit push' does: the shared secret is derived from the
    \\password (from --password, else ZIT_PASSWORD, else a no-echo prompt) and, on
    \\success, saved in the repository at 0600 so a later --user needs no password.
    \\
    \\options:
    \\  --user U        authenticate as login U (sync §6)
    \\  --password P    U's password (prompted without echo if omitted)
    \\  -h, --help      show this help and exit
    \\
;
pub const remote_help =
    \\usage: zit remote [URL | --unset] [-h|--help]
    \\
    \\Show, set, or clear the default sync remote — the peer URL that 'zit sync',
    \\'zit pull', and 'zit push' use when you do not name one. With no argument,
    \\print the saved remote (or 'no remote set'). With URL, save it. With --unset,
    \\clear it.
    \\
    \\There is a single default remote, not a named-remote registry. URL is stored
    \\verbatim and used anonymously — never put a credential in it; authenticate a
    \\push or sync with --user instead.
    \\
    \\options:
    \\      --unset     clear the saved remote
    \\  -h, --help      show this help and exit
    \\
;
pub const submanifest_help =
    \\usage: zit submanifest [show | enable [--k N] | disable | retune --k N |
    \\                       analyze [--names PATH]] [-h|--help]
    \\
    \\Show and change this repository's sub-manifest settings, or measure the
    \\partition parameter. A sub-manifest lets a check-in carry its file set as a
    \\tree of shared, content-addressed slices instead of one flat F-card list.
    \\
    \\Emission is OFF by default and turning it on is a deliberate act with a
    \\consequence: from the first tree this repository writes, peers below Fossil
    \\2.29 are refused every S-bearing artifact rather than handed one they cannot
    \\read, and turning the setting back off cannot retire artifacts already
    \\written. The specification is provisional; a repository containing one must
    \\be treated as a disposable test fixture.
    \\
    \\actions:
    \\  show      print the settings and what commits currently emit (default)
    \\  enable    turn emission on, pinning the partition parameter the first time
    \\  disable   stop emitting new tree manifests (existing ones are permanent)
    \\  retune    re-pin the partition parameter — always manual, never inferred.
    \\            The next commit rewrites every leaf, once. No history changes.
    \\  analyze   measure the objective for k = 1..12 over a name set and report
    \\            the optimum. Changes nothing.
    \\
    \\options:
    \\      --k N       the partition parameter, 1..12
    \\      --names PATH  analyze a newline-separated, sorted name list from PATH
    \\                    instead of the current check-in's file set
    \\  -h, --help      show this help and exit
    \\
    \\These settings are LOCAL to this repository and do not propagate to clones.
    \\
;
pub const info_help =
    \\usage: zit info NAME [--no-cache]
    \\
    \\Resolve NAME to a single check-in and describe it. NAME may be a full
    \\40- or 64-character artifact ID, a unique ID prefix, a branch or tag name,
    \\or one of the reserved forms (tip, rid:, tag:, root:, start:, merge-in:).
    \\Resolution follows recency, not open-leaf status: a name can resolve to a
    \\closed or non-leaf check-in that 'zit leaves' would not list.
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const amend_help =
    \\zit amend NAME [--comment TEXT] [--user NAME] [--date ISO] [--branch NAME]
    \\
    \\Attach display overrides to a check-in by minting one control artifact.
    \\History is never rewritten; the original check-in is untouched.
    \\
    \\  --comment TEXT   override the displayed comment
    \\  --user NAME      override the displayed author
    \\  --date ISO       override the displayed date (YYYY-MM-DDTHH:MM:SS)
    \\  --branch NAME    move the check-in (and its descendants) to branch NAME
    \\  -h, --help       show this help
    \\
    \\At least one override flag is required.
    \\
;

pub const ls_help =
    \\usage: zit ls [VERSION] [--no-cache]
    \\
    \\List the files of a check-in, one per line as `<blob-id> <filename>`, in
    \\canonical filename order. VERSION selects the check-in the same way 'zit
    \\info' does — a full 40- or 64-character artifact ID, a unique ID prefix, a
    \\branch or tag name, or a reserved form (tip, rid:, tag:, root:, start:,
    \\merge-in:). With VERSION omitted, the current checkout is listed; an unborn
    \\checkout has nothing to list and is an error.
    \\
    \\The listing is the effective file set: a delta check-in is merged with its
    \\baseline and deleted files are omitted.
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const cat_help =
    \\usage: zit cat FILE [VERSION] [--no-cache]
    \\
    \\Write the contents of FILE at a check-in to standard output, verbatim — no
    \\trailing newline is added and binary content passes through unchanged. FILE
    \\is a pathname within the check-in; VERSION selects the check-in the same way
    \\'zit info' does — a full 40- or 64-character artifact ID, a unique ID prefix,
    \\a branch or tag name, or a reserved form (tip, rid:, tag:, root:, start:,
    \\merge-in:). With VERSION omitted, the current checkout is used; an unborn
    \\checkout has nothing to read and is an error.
    \\
    \\FILE is resolved against the effective file set: in a delta check-in the
    \\file's own entry wins over the baseline, and a file deleted at this check-in
    \\is reported as not found. The emitted bytes are verified to match the file's
    \\recorded hash.
    \\
    \\options:
    \\      --no-cache  bypass the cache and re-derive from the full log,
    \\                  re-verifying whole-log integrity (the cache is refreshed)
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const artifact_help =
    \\usage: zit artifact HASH [--raw]
    \\
    \\Describe or dump a single stored artifact addressed by its hash. HASH is a full
    \\or abbreviated (>=4 character) lowercase hexadecimal artifact ID, resolved
    \\against every stored artifact — file blobs included. Branch and tag names are
    \\not accepted; use 'zit info' for those.
    \\
    \\Without --raw, print a summary: the full hash, the artifact's size, its type,
    \\and — for a structural artifact — one line per card and whether its Z
    \\self-checksum verifies. A blob reports its size and whether it is binary.
    \\
    \\With --raw, write the artifact's exact stored bytes to standard output,
    \\verbatim — no trailing newline is added and binary content passes through
    \\unchanged. Hashing those bytes reproduces HASH.
    \\
    \\options:
    \\      --raw       write the exact stored bytes instead of a summary
    \\  -R, --repo <path>
    \\                read this repository instead of the one found from the
    \\                current directory; <path> is the directory holding '.zit'
    \\                (or the '.zit' file itself). No working tree is required.
    \\  -h, --help      show this help and exit
    \\
;

pub const diff_help =
    \\usage: zit diff [-h|--help]
    \\
    \\Compare the managed working-tree files with the current check-in. Output is
    \\metadata only, sorted by pathname: A for additions, M for content or
    \\executable-mode changes, and D for files removed from management or the
    \\working tree.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

/// 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.
pub 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).
    \\
    \\Naming a directory adds every untracked file beneath it, recursively;
    \\`zit add .` adds the whole tree. zit's control files are never added.
    \\
    \\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, not a regular file, 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.
pub 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. Naming a directory forgets
    \\every tracked file beneath it, whether or not the directory still exists.
    \\
    \\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, 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)
    \\
;

/// Verbatim help text (contracts/mv.md §Help). Model-honest wording ("tracked",
/// "check-in", "recorded", the explicit no-clobber / no-force / no-undo posture).
/// Ends in exactly one newline. The e2e suite compares stdout against an
/// independent transcription of this block, so drift is caught.
pub const mv_help =
    \\usage: zit mv OLD NEW
    \\
    \\Rename a tracked file: move the working file from OLD to NEW on disk, swap
    \\the tracked-name entry, and record the rename so your next `zit commit`
    \\records NEW with OLD as its former name.
    \\
    \\OLD must be tracked and present in the working tree; NEW must not already be
    \\tracked or exist on disk. Renaming never overwrites -- a collision is
    \\refused, and there is no --force. mv takes exactly two operands; it is never
    \\a glob or multi-file move, and it does not rename directories.
    \\
    \\mv is not undone by `zit undo`; to reverse a rename, run `zit mv NEW OLD`.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the file was renamed
    \\  1  OLD is not tracked or missing, NEW is already tracked or on disk, or
    \\     this is not a zit repository
    \\  2  usage error (wrong operand count, or an unknown option)
    \\
;

/// Verbatim `zit stat` help text. It states the one thing a reader of these
/// numbers has to know — that `content-bytes` and `store-bytes` are different
/// measurements and neither substitutes for the other — because the field list
/// alone would let someone read the smaller number as "the file, minus some
/// slack". Ends in exactly one newline. The e2e suite compares stdout against
/// an independent transcription of this block, so drift is caught.
pub const stat_help =
    \\usage: zit stat [--artifacts] [--type=full|delta] [-h|--help]
    \\
    \\Report what the repository store actually holds. Every number is read
    \\from the frames in `.zit` — nothing is estimated, and nothing is derived
    \\from the file's size.
    \\
    \\Output is one 'field value' line per fact, so it reads by machine:
    \\
    \\  store-bytes           the `.zit` file itself, in bytes
    \\  header-bytes          the store header
    \\  content-bytes         artifact payload bytes, summed frame by frame
    \\  frame-overhead-bytes  frame headers and 8-byte padding for those frames
    \\  config-bytes          the repository's config record
    \\  shun-bytes            the repository's shun table
    \\  shun-rows             rows in that table
    \\  artifacts             stored artifacts (the config record is not one)
    \\  full-form             artifacts stored whole
    \\  full-form-bytes       content-bytes held by those artifacts
    \\  deltas                artifacts stored against a base
    \\  delta-bytes           content-bytes held by those artifacts
    \\
    \\content-bytes and store-bytes are different measurements and must not be
    \\substituted for one another: the store also carries the header, a header
    \\per frame, and padding, so it is always the larger number. The five byte
    \\fields plus header-bytes account for the file exactly.
    \\
    \\full-form-bytes and delta-bytes PARTITION content-bytes; they are not
    \\further components of the file and must not be added to the accounting
    \\above. Read them against full-form/deltas rather than instead of them: a
    \\store is routinely a minority full-form by artifact and a large majority
    \\full-form by byte, because an artifact is stored whole exactly when
    \\delta did not pay -- the large, poorly-compressible ones.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\  --artifacts   list the stored artifact hashes instead, one per line,
    \\                in ascending hash order
    \\  --type=KIND   with --artifacts, list only 'full' or 'delta' artifacts
    \\
    \\exit status:
    \\  0  the report was written
    \\  1  this is not a zit repository, or `.zit` could not be read
    \\  2  usage error (an unknown option, a repeated --type, a --type without
    \\     --artifacts, or an unexpected argument)
    \\
;

/// Verbatim B-status-16 help text (contracts/status.md §Help). Model-honest
/// wording ("check-in", "trunk", "unborn", "tracked", "artifact hash" — never
/// git's "index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary
/// (DX D2) rather than hiding it. Ends in exactly one newline. The e2e suite
/// compares stdout against an independent transcription of this block, so drift
/// is caught.
pub const status_help =
    \\usage: zit status [-h|--help]
    \\
    \\Show the state of the working copy: the current branch, the current
    \\check-in (if any), and the state of your tracked files.
    \\
    \\zit has no staging area. `status` reports the files you marked with
    \\`zit add` and how each one stands in the working tree:
    \\
    \\  added <path>     tracked and new — it will enter your next check-in
    \\  edited <path>    tracked, and its bytes or mode differ from the check-in
    \\  missing <path>   tracked, but not present in the working tree
    \\
    \\A tracked file whose contents match the current check-in is not listed.
    \\
    \\Until your first `zit commit` the branch 'trunk' is unborn: there is no
    \\check-in yet, so every tracked file is reported as 'added' (it will form
    \\the root check-in). The current check-in, once one exists, is shown as its
    \\full artifact hash.
    \\
    \\Two things `status` does not report, because it walks only your tracked
    \\files: a file deleted from the check-in but no longer tracked (`zit diff`
    \\lists it) and untracked "extra" files (`zit extras` lists them).
    \\
    \\options:
    \\  -h, --help   show this help and exit
    \\
    \\exit status:
    \\  0  the working-copy state was reported (with or without changes)
    \\  1  this is not a zit repository, or its state could not be read
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// Verbatim `zit extras` help (contracts/extras.md §Help). The house
/// `parseNoPositionalArgs`-verb help shape (status/diff/timeline). Ends in
/// exactly one newline; the e2e suite compares stdout against an independent
/// transcription, so any drift is caught.
pub const extras_help =
    \\usage: zit extras [-h|--help]
    \\
    \\List every file in the checkout that zit is not tracking — each on-disk file
    \\under the checkout root that is neither a tracked file nor a zit control file.
    \\Paths are shown relative to the checkout root, one per line, sorted. Files in
    \\subdirectories are included; directories themselves are not. Symlinks are
    \\listed but never followed. With nothing untracked, the output is empty.
    \\
    \\options:
    \\  -h, --help    show this help and exit
    \\
;

/// Verbatim `zit clean` help (contracts/clean.md §Help). Names the destructive,
/// non-undoable, non-interactive posture up front and the `--dry-run`-wins
/// precedence. Ends in exactly one newline; the e2e suite compares stdout
/// against an independent transcription, so any drift is caught.
pub const clean_help =
    \\usage: zit clean [--dry-run] [--force]
    \\
    \\Delete every file in the checkout that zit is not tracking — the same set
    \\`zit extras` lists. This is destructive and cannot be undone: `zit undo` does
    \\not recover a clean.
    \\
    \\By default clean refuses and deletes nothing: it reports how many untracked
    \\files would be deleted and stops. Pass --force to delete them, or --dry-run to
    \\preview the exact list first. clean never prompts.
    \\
    \\Tracked files and zit's control files are never touched. Only files are
    \\deleted; directories left empty are not pruned.
    \\
    \\options:
    \\  --dry-run     list what would be deleted, one path per line; delete nothing
    \\  --force       delete the untracked files
    \\  -h, --help    show this help and exit
    \\
    \\If both --dry-run and --force are given, --dry-run wins and nothing is deleted.
    \\
    \\exit status:
    \\  0  nothing to delete, a --dry-run preview, or every file deleted
    \\  1  refused (no --force), a file could not be deleted, or not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// Verbatim `zit addremove` help (contracts/addremove.md §Help). Names the
/// track-new + forget-deleted model, the tracked-only (never file contents)
/// posture, and the ADDED/REMOVED report shape. Ends in exactly one newline;
/// the e2e suite compares stdout against an independent transcription, so any
/// drift is caught.
pub const addremove_help =
    \\usage: zit addremove [--dry-run]
    \\
    \\Reconcile the tracked set with the working tree in one pass: mark every
    \\untracked file to include in your next check-in, and stop tracking every
    \\file you deleted from disk. This is the set `zit extras` lists, added, plus
    \\the tracked files now missing, removed — fossil and hg spell it `addremove`;
    \\git spells it `add -A`.
    \\
    \\addremove changes only what zit tracks; it never edits, creates, or deletes
    \\a working file, and the next `zit commit` records the result. Each action is
    \\reported, sorted and relative to the checkout root:
    \\
    \\  ADDED <path>     a newly tracked file
    \\  REMOVED <path>   a tracked file no longer on disk
    \\
    \\A tracked file that is merely modified is left alone (content does not affect
    \\membership), and zit's control files are never tracked. With nothing to do,
    \\the output is empty.
    \\
    \\options:
    \\  --dry-run     report what would change, one action per line; change nothing
    \\  -h, --help    show this help and exit
    \\
    \\exit status:
    \\  0  the tracked set was reconciled (with or without changes), or a --dry-run preview
    \\  1  a path could not be tracked, or this is not a zit repository
    \\  2  usage error (an unknown option or an unexpected argument)
    \\
;

/// Verbatim B-commit-20 help text (contracts/commit.md §Help). Model-honest
/// wording ("check-in", "root check-in", "trunk", "artifact hash", "tracked",
/// "no staging area", "immutable", "no amend/--force" — never git's
/// "commit"/"index"/"staged"/"HEAD"), and it teaches the slice-1 scope boundary
/// (DX D2). Ends in exactly one newline. The e2e suite compares stdout against
/// an independent transcription of this block, so drift is caught.
pub const commit_help =
    \\usage: zit commit -m <message> [--user <login>] [--branch <name>] [--close]
    \\                  [-q|--quiet] [--no-submanifest-advice] [--] [<path>...]
    \\
    \\Record a check-in: save the current contents of your tracked files as a
    \\new, permanent point in history on the current branch.
    \\
    \\zit has no staging area. `commit` reads each tracked file straight from
    \\the working tree right now — there is nothing to stage first. With no
    \\paths, every tracked file is captured; name one or more paths to commit
    \\just those (the rest of the tree is carried forward unchanged).
    \\
    \\The first commit in a new repository creates the root check-in and starts
    \\the default branch 'trunk'. Each check-in is named by its full artifact
    \\hash (SHA3-256).
    \\
    \\--branch <name> starts a new branch at this check-in: the check-in moves
    \\onto <name> and later commits inherit it. <name> must not already name an
    \\open branch, and there must be a parent to fork from. --close marks this
    \\check-in a closed leaf, so it no longer shows as an open head of its branch.
    \\
    \\History is immutable: a check-in is never rewritten. There is no amend and
    \\no --force; to undo a file, change it and commit again. To stop tracking a
    \\file (and record its deletion), use `zit rm` then commit.
    \\
    \\A check-in message is required (-m). Only regular files are captured.
    \\
    \\On a repository with sub-manifests enabled and pinned, a commit that
    \\drifts far enough from the analyzed optimum (draft-fossil-submanifests-00
    \\§6.4) prints one advisory line and, interactively, offers to retune.
    \\"Far enough" is zit's §6.4 relative margin m: a re-tune is advised only
    \\when it would improve the analyzed read cost by more than 15% of the
    \\pinned value's own cost. --no-submanifest-advice suppresses the advisory
    \\entirely. Never affects whether the commit itself succeeds.
    \\
    \\options:
    \\  -m, --message <message>   the check-in comment (required)
    \\      --user <login>        author to record (default: $USER)
    \\      --branch <name>       start a new branch at this check-in
    \\      --close               mark this check-in a closed leaf
    \\  -q, --quiet               do not print the check-in line
    \\      --no-submanifest-advice
    \\                            suppress the sub-manifest drift advisory
    \\  -h, --help                show this help and exit
    \\
    \\exit status:
    \\  0  the check-in was created
    \\  1  nothing to commit, a tracked file is missing, an empty message, an
    \\     existing branch name, or this is not a zit repository
    \\  2  usage error (no message, or an unknown option)
    \\
;

test "commit help states the live §6.4 margin, not a copy of it" {
    // draft-fossil-submanifests-00 §6.4 requires the relative margin `m` to be
    // DOCUMENTED, and this help block is the operator-facing statement that
    // discharges it (`submanifest.drift_margin_pct` names it as such). The e2e
    // help comparison pins this text against its transcription in `e2e.zig`,
    // but nothing there pins either against the constant the code actually
    // gates on -- both could keep saying "15%" after the constant moved. This
    // renders the constant and requires the sentence to contain it, so a change
    // to `drift_margin_pct` fails here until the prose is updated too.
    const rendered = try std.fmt.allocPrint(
        std.testing.allocator,
        "more than {d}% of",
        .{zit.submanifest.drift_margin_pct},
    );
    defer std.testing.allocator.free(rendered);
    try std.testing.expect(std.mem.indexOf(u8, commit_help, rendered) != null);
}

test {
    // Pull the CLI harness's inline unit tests into `zig build test`. The
    // repo-view loader and its cache now live in the `zit` module and aggregate
    // through root.zig, so they are not re-imported here.
    _ = @import("cli.zig");
    _ = @import("transport.zig");
    _ = @import("serve.zig");
    _ = @import("sync_driver.zig");
    _ = @import("sync_driver_test.zig");
    // The pure three-way merge engine behind `zit merge` (contracts/merge.md).
    // It imports only std, so its inline tests ride the CLI test binary here
    // (no build.zig change) exactly as `extras`/`undo` ride their importers.
    _ = @import("merge3.zig");
    // The pure per-line provenance engine behind `zit annotate`
    // (contracts/annotate.md). Imports only std + merge3, so its inline tests
    // ride the CLI test binary here the same way.
    _ = @import("annotate.zig");
    _ = @import("cmd/sync.zig");
    _ = @import("submanifest_benchmark.zig");
    _ = @import("cmd/tracking.zig");
    _ = @import("cmd/history.zig");
    _ = @import("cmd/checkout.zig");
    _ = @import("cmd/verify.zig");
}

/// A fixed `Ambient` for the in-process dispatch tests: a pinned epoch (no wall
/// clock) and no environment author. Verbs other than `commit` ignore it; the
/// commit dispatch tests that reach assembly pin their own values via the e2e
/// harness (the real binary), so this constant is only the argument shape.
const test_amb: cli.Ambient = .{ .now_epoch = 0, .user_env = null };

// --- status error-mapper unit tests (B-status-10/-11; pure switches) -------

test "status B-status-10: CheckoutError maps to the contract messages and exit 1" {
    const Case = struct { err: checkout.CheckoutError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit status: cannot read checkout state: .zit-checkout is corrupt\n" },
        .{ .err = error.Storage, .want = "zit status: cannot read checkout state: input/output error\n" },
    };
    for (cases) |c| {
        var buf: [256]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 };
        try std.testing.expectEqual(cli.Exit.failure, checkoutFail(s, "status", c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "status B-status-11: ManagedError maps to the contract messages and exit 1" {
    const Case = struct { err: managed.ManagedError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.Corrupt, .want = "zit status: cannot read tracked files: .zit-managed is corrupt\n" },
        .{ .err = error.Storage, .want = "zit status: cannot read tracked files: input/output error\n" },
        .{ .err = error.TooLong, .want = "zit status: cannot read tracked files: a tracked path is too long\n" },
        .{ .err = error.TooMany, .want = "zit status: cannot read tracked files: too many tracked files\n" },
        .{ .err = error.OutOfMemory, .want = "zit status: out of memory\n" },
    };
    for (cases) |c| {
        var buf: [256]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 };
        try std.testing.expectEqual(cli.Exit.failure, managedFail(s, "status", c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

test "status B-status-9: stateOom renders the out-of-memory message and exit 1" {
    var buf: [64]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 };
    try std.testing.expectEqual(cli.Exit.failure, stateOom(s, "status"));
    try std.testing.expectEqualStrings("zit status: out of memory\n", w.buffered());
}

// --- status dispatch: the io/alloc-free paths, driven in-process -----------
//
// `--help` and the two usage errors touch neither the filesystem nor the
// allocators, so they run through `run()` over fixed-buffer writers — the same
// technique the unknown-command dispatch tests use — with the exact argv.

/// Drives `run()` for `zit status <tail...>` over fixed-buffer writers,
/// returning the exit code and the captured stdout/stderr (valid until the next
/// call). Sized to hold the whole `status_help` block on stdout.
fn runStatusCapture(tail: []const []const u8) struct { code: cli.Exit, out: []const u8, err: []const u8 } {
    const S = struct {
        var out_buf: [2048]u8 = undefined;
        var err_buf: [512]u8 = undefined;
    };
    var out = Io.Writer.fixed(&S.out_buf);
    var err = Io.Writer.fixed(&S.err_buf);
    const streams: cli.Streams = .{ .out = &out, .err = &err };
    var argv_buf: [8][]const u8 = undefined;
    argv_buf[0] = "zit";
    argv_buf[1] = "status";
    for (tail, argv_buf[2..][0..tail.len]) |src, *dst| dst.* = src;
    const code = run(
        std.testing.allocator,
        std.testing.io,
        std.testing.allocator,
        argv_buf[0 .. 2 + tail.len],
        streams,
        test_amb,
    );
    return .{ .code = code, .out = out.buffered(), .err = err.buffered() };
}

test "status B-status-12: an unknown option is a usage error (exit 2, stderr, empty stdout)" {
    const r = runStatusCapture(&.{"-x"});
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit status: unknown option '-x'\ntry 'zit status --help'\n",
        r.err,
    );
}

test "status B-status-13: an unexpected argument is a usage error (exit 2, stderr, empty stdout)" {
    const r = runStatusCapture(&.{"foo"});
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit status: unexpected argument 'foo'\ntry 'zit status --help'\n",
        r.err,
    );
}

test "status B-status-14: --help prints the help to stdout (exit 0, empty stderr)" {
    const r = runStatusCapture(&.{"--help"});
    try std.testing.expectEqual(cli.Exit.ok, r.code);
    try std.testing.expectEqualStrings(status_help, r.out);
    try std.testing.expectEqualStrings("", r.err);
}

test "status B-status-16 DX-D4: 'zit help status' routes to the verb's own help" {
    // `run()`'s help arm touches neither io nor the allocators, so the topic
    // routing is driven in-process; the stdout must be byte-identical to
    // `status --help` (the DX-D4 fix: `zit help status` no longer prints the
    // general command list that implied the verb was absent).
    var out_buf: [2048]u8 = undefined;
    var err_buf: [256]u8 = undefined;
    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", "help", "status" },
        streams,
        test_amb,
    );
    try std.testing.expectEqual(cli.Exit.ok, code);
    try std.testing.expectEqualStrings(status_help, out.buffered());
    try std.testing.expectEqualStrings("", err.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 {
        return run_argv(&.{ "zit", verb });
    }

    /// Like `run_verb` but with the full argv (post-"zit" tokens included), for
    /// verbs whose diagnostic depends on an option. Only safe for control paths
    /// that return before `discoverRoot` (which allocates into the arena — here
    /// `testing.allocator` — and is freed en masse by a real arena in `main`).
    fn run_argv(argv: []const []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, argv, streams, test_amb);
        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);
}

test "cli D2 B-init-12: 'rebase' teaches immutable history, exit 2 stderr-only" {
    try expectReflex("rebase", "history is immutable");
}
test "cli amend B-amend-12: 'amend' dispatches to the real handler, not the git-reflex lesson" {
    // amend is a real verb now (removed from `reflex_table`): `zit amend` with no
    // NAME is amend's OWN usage error, never the immutable-history reflex lesson —
    // teaching against a fossil-semantics amend with that lesson would conflate it
    // with git's history-rewriting amend.
    const r = RunCapture.run_verb("amend");
    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, "is not a zit command") == null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "no name given") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "try 'zit amend --help'") != null);
}
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 push: an unknown option is a usage error before any I/O (real verb, dispatched)" {
    // `push` is a real handler, not a git-reflex redirect. Under M4 an omitted URL
    // is no longer an immediate usage error (it falls back to the saved remote),
    // but a malformed option is still rejected by the arg parser before any I/O —
    // exit 2, the diagnostic on stderr, empty stdout — proving real dispatch.
    const r = RunCapture.run_argv(&.{ "zit", "push", "--nope" });
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit push: unknown option '--nope'\ntry 'zit push --help'\n",
        r.err,
    );
}
test "cli pull: an unknown option is a usage error before any I/O (real verb, dispatched)" {
    // `pull` is a real handler, not a git-reflex redirect. Under M4 an omitted URL
    // falls back to the saved remote rather than erroring immediately; a malformed
    // option is still rejected before any I/O — exit 2, stderr-only.
    const r = RunCapture.run_argv(&.{ "zit", "pull", "--nope" });
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit pull: unknown option '--nope'\ntry 'zit pull --help'\n",
        r.err,
    );
}

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 commit B-commit-5: 'zit commit' with no -m is a usage error (real verb, dispatched)" {
    // Regression: `commit` is now a real handler, not a planned stub. `run()`'s
    // commit arm raises the missing-message usage error before any I/O, so this
    // is driven in-process — exit 2, stderr the B-commit-5 message, empty stdout.
    const r = RunCapture.run_verb("commit");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit commit: a check-in message is required: -m <message>\ntry 'zit commit --help'\n",
        r.err,
    );
}
test "cli clone: 'zit clone' with no arguments is a usage error (real verb, dispatched)" {
    // `clone` is now a real handler, not a planned stub. `run()`'s clone arm
    // parses the empty argument list and raises the missing-URL usage error
    // before any I/O — exit 2, the diagnostic on stderr, empty stdout.
    const r = RunCapture.run_verb("clone");
    try std.testing.expectEqual(cli.Exit.usage, r.code);
    try std.testing.expectEqualStrings("", r.out);
    try std.testing.expectEqualStrings(
        "zit clone: no repository URL given\ntry 'zit clone --help'\n",
        r.err,
    );
}

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 typo of 'update' suggests it (every dispatched verb is suggestable)" {
    // `update` is a real dispatched verb, so a near-miss must land on it. Guards
    // against the suggester's candidate list drifting from the dispatched set.
    const r = RunCapture.run_verb("updat");
    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 'updat'") != null);
    try std.testing.expect(std.mem.indexOf(u8, r.err, "did you mean 'update'?") != 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);
}

// --- commit error-mapper unit tests (B-commit-15; pure switches) -----------

test "update: ApplyError maps to the contract messages and exit 1, exhaustively over all 23 variants" {
    const target_id = "1" ** 40;
    const Case = struct { err: update.ApplyError, want: []const u8 };
    const cases = [_]Case{
        .{ .err = error.OutOfMemory, .want = "zit update: out of memory\n" },
        .{
            .err = error.InvalidCheckinId,
            .want = "zit update: invalid check-in ID '" ++ target_id ++ "' (expected 40 or 64 lowercase hexadecimal digits)\n",
        },
        .{ .err = error.CheckinNotFound, .want = "zit update: check-in '" ++ target_id ++ "' not found\n" },
        .{
            .err = error.CheckinContentMissing,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content is missing\n",
        },
        .{
            .err = error.CheckinNameMismatch,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' content does not match its name\n",
        },
        .{ .err = error.TargetNotManifest, .want = "zit update: check-in '" ++ target_id ++ "' is not a valid manifest\n" },
        .{
            .err = error.BaselineNotFound,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline is not found\n",
        },
        .{
            .err = error.BaselineContentMissing,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content is missing\n",
        },
        .{
            .err = error.BaselineNameMismatch,
            .want = "zit update: cannot read repository: check-in '" ++ target_id ++ "' baseline content does not match its name\n",
        },
        .{
            .err = error.BaselineNotManifest,
            .want = "zit update: check-in '" ++ target_id ++ "' baseline is not a valid manifest\n",
        },
        .{
            .err = error.BaselineIsDelta,
            .want = "zit update: check-in '" ++ target_id ++ "' baseline is not fully resolvable\n",
        },
        .{
            .err = error.StateRecordInvalid,
            .want = "zit update: cannot read repository: " ++ update.state_record_invalid_reason ++ "\n",
        },
        .{ .err = error.InvalidPath, .want = "zit update: check-in '" ++ target_id ++ "' names an invalid file path\n" },
        .{ .err = error.ReservedPath, .want = "zit update: check-in '" ++ target_id ++ "' names a reserved file path\n" },
        .{
            .err = error.SymlinkUnsupported,
            .want = "zit update: check-in '" ++ target_id ++ "' contains an unsupported symlink entry\n",
        },
        .{ .err = error.CurrentStateInvalid, .want = "zit update: current working-tree state is invalid\n" },
        .{ .err = error.WorktreeNotClean, .want = "zit update: working tree is not clean\n" },
        .{ .err = error.Corrupt, .want = "zit update: cannot read repository: repository is corrupt\n" },
        .{ .err = error.UnmanagedCollision, .want = "zit update: an untracked file or directory is in the way\n" },
        .{ .err = error.Storage, .want = "zit update: cannot update working tree: input/output error\n" },
        .{ .err = error.TransactionUnavailable, .want = "zit update: cannot create a transaction directory\n" },
        .{
            .err = error.RollbackFailed,
            .want = "zit update: update failed and could not be rolled back; the previous working-tree state is retained for recovery\n",
        },
        .{
            .err = error.CleanupFailed,
            .want = "zit update: update committed, but the transaction directory could not be removed\n",
        },
    };
    for (cases) |c| {
        var buf: [256]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 };
        try std.testing.expectEqual(cli.Exit.failure, updateApplyFail(s, target_id, c.err));
        try std.testing.expectEqualStrings(c.want, w.buffered());
    }
}

/// Render one `ApplyError` through `updateApplyFail` and return the stderr text.
fn updateFailText(buf: []u8, err: update.ApplyError) []const u8 {
    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 };
    _ = updateApplyFail(s, "1" ** 40, err);
    return w.buffered();
}

// `Corrupt` and `StateRecordInvalid` are two different claims and must never
// share a diagnostic again. `Corrupt` says stored bytes do not match the name
// they are stored under — real damage. `StateRecordInvalid` says nothing about
// the repository's integrity: a stored artifact was not usable as a state
// record. An intact fossil clone reaches it routinely, because fossil stores
// and syncs artifacts it itself declines to crosslink, so answering it with a
// corruption claim accuses an undamaged repository.
test "update: the two read-path failures do not share a diagnostic" {
    var a: [256]u8 = undefined;
    var b: [256]u8 = undefined;
    const corrupt = updateFailText(&a, error.Corrupt);
    const state = updateFailText(&b, error.StateRecordInvalid);

    try std.testing.expect(!std.mem.eql(u8, corrupt, state));

    // Only the integrity failure may allege corruption.
    try std.testing.expect(std.mem.indexOf(u8, corrupt, "corrupt") != null);
    try std.testing.expect(std.mem.indexOf(u8, state, "corrupt") == null);
}

test "tracked file size limit admits its boundary" {
    const limit: u64 = max_tracked_file_bytes;
    try std.testing.expect(trackedFileSizeAllowed(limit - 1));
    try std.testing.expect(trackedFileSizeAllowed(limit));
    try std.testing.expect(!trackedFileSizeAllowed(limit + 1));
}

test "the tracked-file ceiling is the storage frame ceiling, not a policy number" {
    // The comptime assert beside the declaration is the real enforcement; this
    // states the same tie as a readable claim, and says WHY it is not tunable:
    // `logstore.put`/`putArtifact` assert on `max_payload`, so admitting a file
    // above it would trip an assert during commit rather than refuse the file.
    try std.testing.expectEqual(logstore.max_payload, @as(u64, max_tracked_file_bytes));

    // The largest admissible file is exactly one storable frame — the boundary
    // the assert allows, not one byte inside or outside it.
    try std.testing.expect(trackedFileSizeAllowed(logstore.max_payload));
    try std.testing.expect(!trackedFileSizeAllowed(logstore.max_payload + 1));
}

// --- renderBranchList unit tests (repo-state §6.1/§6.3/§6.4/§9) -------------
// The derived §6.3 state (which rid represents a branch, its closed status, the
// grouping) is proven in repostate_test.zig; these tests fix only the CLI
// presentation over hand-built BranchEntry values transcribed from §6.3.

const BranchEntry = repostate.RepoState.BranchEntry;

/// Renders `entries`/`current` into a fixed buffer and returns the written
/// slice — the shared body of the presentation tests.
fn renderBranchesToBuf(buf: []u8, entries: []const BranchEntry, current: ?[]const u8) []const u8 {
    var w = Io.Writer.fixed(buf);
    renderBranchList(&w, entries, current);
    return w.buffered();
}

test "renderBranchList §6.3: a representative-closed branch renders \"(closed)\"" {
    // §6.3 divergence: a forked branch whose greatest-mtime representative is
    // closed lists as closed even while an older leaf stays open. The closed
    // flag is the representative-row property; render appends the marker.
    const entries = [_]BranchEntry{
        .{ .name = "trunk", .tip = 1, .mtime = 3, .count = 2, .closed = true },
    };
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("  trunk  (closed)\n", renderBranchesToBuf(&buf, &entries, null));
}

test "renderBranchList §6.3: multiple branches render name-ascending, current marked once" {
    // `branches` returns entries name-ascending; render preserves that order and
    // marks exactly the current branch, indenting the rest.
    const entries = [_]BranchEntry{
        .{ .name = "feature", .tip = 2, .mtime = 5, .count = 1, .closed = false },
        .{ .name = "trunk", .tip = 1, .mtime = 9, .count = 3, .closed = false },
    };
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings(
        "* feature\n  trunk\n",
        renderBranchesToBuf(&buf, &entries, "feature"),
    );
}

test "renderBranchList §9: a forked branch (two leaves) renders as one line" {
    // §9: a branch with more than one leaf is forked but is still a single §6.3
    // entry (count > 1), so it lists on exactly one line.
    const entries = [_]BranchEntry{
        .{ .name = "trunk", .tip = 7, .mtime = 4, .count = 2, .closed = false },
    };
    var buf: [64]u8 = undefined;
    const out = renderBranchesToBuf(&buf, &entries, "trunk");
    try std.testing.expectEqualStrings("* trunk\n", out);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\n"));
}

test "renderBranchList §6.4: a null current-branch marks no row" {
    // Negative space: with no resolvable current branch, no line carries "*".
    const entries = [_]BranchEntry{
        .{ .name = "feature", .tip = 2, .mtime = 5, .count = 1, .closed = false },
        .{ .name = "trunk", .tip = 1, .mtime = 9, .count = 1, .closed = false },
    };
    var buf: [64]u8 = undefined;
    const out = renderBranchesToBuf(&buf, &entries, null);
    try std.testing.expectEqualStrings("  feature\n  trunk\n", out);
    try std.testing.expect(std.mem.indexOfScalar(u8, out, '*') == null);
}

test "renderBranchList §6.1: the configured main-branch name marks its row (not a literal)" {
    // The current-branch name is whatever branchOf returned through the config
    // main-branch — here "mainline", not "trunk". Render marks that row, so a
    // non-trunk default is honored end to end.
    const entries = [_]BranchEntry{
        .{ .name = "mainline", .tip = 1, .mtime = 9, .count = 1, .closed = false },
        .{ .name = "trunk", .tip = 2, .mtime = 3, .count = 1, .closed = false },
    };
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings(
        "* mainline\n  trunk\n",
        renderBranchesToBuf(&buf, &entries, "mainline"),
    );
}

// ===========================================================================
// `zit pack` — repack/GC tests. Storage on-disk layout is unspecified by the
// RFCs, so the oracle is the store's OWN semantics: every artifact's content
// and name survive a repack byte-for-byte, the set is preserved (no drop), the
// §7.7 name↔content invariant is re-checked before every write, the packed
// bytes are a deterministic function of the source content (idempotent), and a
// crash before the atomic rename leaves the original store untouched. Stores
// are built as FULL frames (the migration shape pack exists to compact),
// through the real `LogStore`/`artifact` — never seeded from fossil.
// ===========================================================================

/// Store `content` as a FULL raw frame and return its 64-hex artifact name.
pub fn packSeedBlob(log: *logstore.LogStore, content: []const u8) ![64]u8 {
    var digest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
    _ = try log.put(&digest, content);
    var name: [64]u8 = undefined;
    _ = zit.artifact.artifactName(.sha3_256, content, &name);
    return name;
}

/// Serialize and store (as a FULL frame) a one-file check-in manifest snapshotting
/// `file_name` → `blob_name`, optionally descended from `parent_name`. Writes the
/// manifest's own 64-hex name into `name_out`. Root check-ins carry a propagating
/// `branch=trunk` T card; children carry the parent P card and inherit trunk.
pub fn packSeedCheckin(
    gpa: std.mem.Allocator,
    log: *logstore.LogStore,
    ts: []const u8,
    parent_name: ?[]const u8,
    file_name: []const u8,
    blob_name: []const u8,
    name_out: *[64]u8,
) !void {
    var pbuf: [1]zit.artifact.PCard = undefined;
    var p_slice: []const zit.artifact.PCard = &.{};
    if (parent_name) |pn| {
        pbuf[0] = .{ .parents = pn, .count = 1 };
        p_slice = pbuf[0..1];
    }
    const fcards = [_]zit.artifact.FCard{
        .{ .filename = file_name, .hash = blob_name, .perm = null, .old_name = null, .mode = .regular },
    };
    const root_tags = [_]zit.artifact.TCard{
        .{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" },
    };
    var m: zit.artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = "c" },
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &fcards,
        .j = &.{},
        .m = &.{},
        .p = p_slice,
        .q = &.{},
        .t = if (parent_name == null) &root_tags else &.{},
    };
    defer m.deinit();
    const bytes = try zit.artifact.create(&m, gpa);
    defer gpa.free(bytes);
    var digest: [digest_len]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
    _ = try log.put(&digest, bytes);
    _ = zit.artifact.artifactName(.sha3_256, bytes, name_out);
}

/// A large, near-identical evolving file: 128 identical lines plus a per-version
/// trailer, so successive versions delta to a fraction of the full blob (the
/// change is materially < 90%). Caller frees.
pub fn packEvolvingBlob(gpa: std.mem.Allocator, version: u8) ![]u8 {
    var buf: std.ArrayList(u8) = .empty;
    errdefer buf.deinit(gpa);
    var i: usize = 0;
    while (i < 128) : (i += 1) try buf.print(gpa, "line {d} of the tracked file\n", .{i});
    try buf.print(gpa, "trailer version {d}\n", .{version});
    return buf.toOwnedSlice(gpa);
}

/// Open a fresh, empty (header + config-less) writable `.zit` in `dir`.
pub fn packOpenWritable(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, fs_out: *storage.FileStorage) !logstore.LogStore {
    const hash_kind: u8 = @intFromEnum(zit.artifact.defaultPolicy(false, false));
    const f = try dir.createFile(io, repo_filename, .{ .read = true, .exclusive = true });
    fs_out.* = storage.FileStorage.init(io, f, 0);
    return logstore.LogStore.open(gpa, fs_out.storageFile(), hash_kind);
}

/// Reopen an existing `.zit` in `dir` read-only.
pub fn packOpenReadOnly(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, fs_out: *storage.FileStorage) !logstore.LogStore {
    const f = try dir.openFile(io, repo_filename, .{});
    const st = try f.stat(io);
    fs_out.* = storage.FileStorage.init(io, f, st.size);
    return logstore.LogStore.openReadOnly(gpa, fs_out.storageFile());
}