Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-common/src/cli.rs

//! The `gunnar` command line, in clap derive.
//!
//! Both halves live in one enum with `#[cfg]` on the variants, so
//! `--no-default-features --features server` produces a binary whose `--help`
//! genuinely has no `clone` in it, rather than one that offers a verb and then
//! fails at runtime.

// Every use is behind a half's feature: with none on, nothing names it (OX-GUNBR 2026-10-28).
#[cfg(any(
    feature = "server",
    feature = "client",
    feature = "control",
    feature = "systemd"
))]
use std::path::PathBuf;

#[cfg(feature = "server")]
use clap::ArgGroup;
#[cfg(any(feature = "server", feature = "control"))]
use clap::ValueEnum;
use clap::{Parser, Subcommand};

/// gunnar — a git server and a git client, in Rust, over SSH and smart HTTP.
#[derive(Debug, Parser)]
#[command(
    name = "gunnar",
    // Not the bare crate version. `gunnar 0.1.0` names a manifest field, not a
    // build — and on 2026-08-04/05 several benchmark runs had to identify the
    // binary under test by file size and mtime because nothing on it said which
    // commit it was; one silently measured a ten-hour-old build. The git
    // description and build date come from `build.rs`: a published package names
    // its commit from `.cargo_vcs_info.json`, and a build that knows no date leaves
    // `, built …` out (GUNNAR_BUILD_DATE_PART is empty) rather than making one up.
    version = concat!(
        env!("CARGO_PKG_VERSION"),
        " (",
        env!("GUNNAR_BUILD_REF"),
        env!("GUNNAR_BUILD_DATE_PART"),
        ")"
    ),
    about = "a git server and a git client, in Rust, over SSH and smart HTTP",
    long_about = "gunnar serves git over SSH (publickey auth, no shell) and, when built with \
                  `--features http` and started with `--http-bind`, over smart HTTP. It \
                  controls itself over gRPC. The client half is a normal git client.\n\n\
                  Two git transports, one implementation: smart HTTP carries the same \
                  pkt-line conversation SSH carries, through the same authorisation check \
                  against the same key table and the same ACL, and an access token is the \
                  HTTP credential where a public key is the SSH one. What is refused is \
                  refused on both.\n\n\
                  What gunnar does NOT serve: git:// ever, being unauthenticated by \
                  construction; the dumb HTTP protocol, which is refused by name; and no \
                  git or ssh subprocess anywhere in the server, on any transport."
)]
pub struct Cli {
    /// Log at `debug` rather than `info`. `RUST_LOG` still wins.
    #[arg(long, short = 'v', global = true)]
    pub verbose: bool,

    /// Where the server keeps its control repository and its repositories.
    #[cfg(feature = "server")]
    #[arg(
        long,
        global = true,
        env = "GUNNAR_DATA_ROOT",
        default_value_os_t = default_data_root(),
        value_name = "DIR"
    )]
    pub data_root: PathBuf,

    #[command(subcommand)]
    pub command: Command,
}

/// `$GUNNAR_DATA_ROOT`, else `$HOME/.local/share/gunnar`, else `./gunnar-data`.
#[cfg(feature = "server")]
pub fn default_data_root() -> PathBuf {
    match std::env::var_os("HOME") {
        Some(home) if !home.is_empty() => PathBuf::from(home).join(".local/share/gunnar"),
        _ => PathBuf::from("gunnar-data"),
    }
}

#[derive(Debug, Subcommand)]
pub enum Command {
    // ── server ─────────────────────────────────────────────────────────────
    /// Start the listeners: SSH for git, gRPC for the control plane, and smart
    /// HTTP for git as well when `--http-bind` asks for it.
    // Boxed: `ServeArgs` is several times the size of every other verb.
    #[cfg(feature = "server")]
    Serve(Box<ServeArgs>),

    /// Namespaces: the `<ns>` in `git@host:<ns>/<repo>.git`.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Namespace(NamespaceCommand),

    /// Repositories.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Repo(RepoCommand),

    /// Principals. Keys and grants hang off these.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Principal(PrincipalCommand),

    /// **The server's own certificate issuer** — identity from a certificate.
    ///
    /// With one of these, creating a user *is* issuing their certificate:
    /// there is no external PKI to run and no second system to keep in step.
    ///
    /// Present in a CONTROL-only build too, where it carries exactly one verb:
    /// `issuer show --at <addr>`. That is not a convenience. `Pki::read_only`
    /// exists so this read "works on a client-shaped binary", and the box
    /// whose CA anyone actually needs to check is the appliance — which has no
    /// shell, so the asking is always done from somewhere else.
    #[cfg(any(feature = "server", feature = "control"))]
    #[command(subcommand)]
    Issuer(IssuerCommand),

    /// Public keys.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Key(KeyCommand),

    /// Mint and revoke the access tokens smart HTTP authenticates with.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Token(TokenCommand),

    /// **The transparency log** over the push attestations.
    ///
    /// The attestation log is append-only by discipline; these verbs are what
    /// make it append-only by evidence. `head` signs what the log currently
    /// holds, `audit` checks every signed head against the records on disk, and
    /// `prove` hands out the inclusion proof a client checks offline.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Attest(AttestCommand),

    /// **Walk the chain backwards from a line of source.**
    ///
    /// Which commit wrote the line, whether that commit is signed by a key this
    /// server knows, which push landed it, and whether that push sits in the
    /// transparency log under a signed tree head.
    ///
    /// Every link is reported as proven, broken or **unproven**, and the three
    /// are different: an unsigned commit is not a failed signature, and a push
    /// with no attestation is not a push that was refused. A chain with an
    /// unproven link is INCOMPLETE, never green.
    #[cfg(feature = "server")]
    Verify(VerifyArgs),

    /// **Who wrote each line of a file** (`verify --line` for the whole file).
    ///
    /// For every line of `<path>` at a revision, the commit that last touched
    /// it and that commit's author — the same line→commit attribution `verify`
    /// closes as link 1, done once per line instead of once for a single line.
    #[cfg(feature = "server")]
    Blame(BlameArgs),

    /// **Find a literal string** across the latest revision of a repository.
    ///
    /// Walks the tree at a ref and scans each text file, returning `path:line`
    /// matches. Bounded by a file-size and a match ceiling; it is a live walk,
    /// not a persistent index.
    #[cfg(feature = "server")]
    Search(SearchArgs),

    /// **The forge**: issues, reviews and merges as signed events (roadmap
    /// phase 1).
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Forge(ForgeCommand),

    /// Give a principal access.
    #[cfg(feature = "server")]
    Grant(GrantArgs),

    /// Take a principal's access away.
    #[cfg(feature = "server")]
    Revoke(RevokeArgs),

    /// Repack a repository's objects.
    #[cfg(feature = "server")]
    Gc(GcArgs),

    /// Read a repository's reference history, and optionally seal it.
    #[cfg(feature = "server")]
    Reflog(ReflogArgs),

    /// WireGuard identity: mint the keypair an appliance is seeded with.
    #[cfg(feature = "server")]
    #[command(subcommand)]
    Wg(WgCommand),

    // ── client ─────────────────────────────────────────────────────────────
    /// Clone a repository over SSH into a working tree.
    #[cfg(feature = "client")]
    Clone(CloneArgs),

    /// Fetch from an SSH remote into an existing repository.
    #[cfg(feature = "client")]
    Fetch(FetchArgs),

    /// List the references an SSH remote advertises, without cloning it.
    ///
    /// The cheapest way to ask whether a remote is reachable, whether the key
    /// authenticates, and whether the repository holds what you expect. Prints
    /// `git ls-remote`'s lines — `<oid>\t<ref>`, `HEAD` first, an annotated tag
    /// followed by its peeled `^{}` line — because scripts parse them.
    #[cfg(feature = "client")]
    #[command(name = "ls-remote")]
    LsRemote(LsRemoteArgs),

    /// Push to an SSH remote (send-pack, implemented here; gix has none).
    #[cfg(feature = "client")]
    Push(PushArgs),

    /// Pull-mirror a remote repository.
    #[cfg(feature = "client")]
    #[command(subcommand)]
    Mirror(MirrorCommand),

    /// Encrypted repositories: the server holds ciphertext only, and only the
    /// members' SSH keys open it.
    ///
    /// Each repository has one key ring, wrapped to every member's SSH PUBLIC
    /// key (`ssh-ed25519`, `ssh-rsa`) in `.gunnar-crypt/keys`. git's clean and
    /// smudge filter seal and open file content on this machine; the server
    /// never sees a key. File names, commit messages, sizes and the shape of
    /// history stay visible (Level 1). See `.nornir/encrypted-repos.md`.
    #[cfg(feature = "client")]
    #[command(subcommand)]
    Crypt(CryptCommand),

    /// **A repository's settings on a forge** (owner, 2026-10-14): show it,
    /// make it public or private, or change its default branch — over the
    /// forge's SSH door, signed in with your SSH key, the way git and `crypt`
    /// reach an installed Gunnar. The server decides who may (its own rules:
    /// the account admin for visibility, a repository admin for the branch).
    ///
    /// `gunnar settings nordisk/knut`,
    /// `gunnar settings nordisk/knut --visibility public`,
    /// `gunnar settings nordisk/knut --default-branch main`.
    #[cfg(feature = "client")]
    Settings(SettingsArgs),

    /// **Create an empty repository on a forge** in the object format you
    /// choose (sha256 unless `--sha1`), over the forge's SSH door: a repository
    /// made by a first push can only be sha1. `gunnar push` does this by itself
    /// for a sha256 repository whose remote does not exist yet.
    #[cfg(feature = "client")]
    Create(CreateArgs),

    /// Show the commit log.
    #[cfg(feature = "client")]
    Log(LogArgs),

    /// Show the working tree status.
    #[cfg(feature = "client")]
    Status(RepoArgs),

    /// Commit everything in the working tree.
    #[cfg(feature = "client")]
    Commit(CommitArgs),

    /// Branches.
    #[cfg(feature = "client")]
    Branch(BranchArgs),

    /// Tags.
    #[cfg(feature = "client")]
    Tag(TagArgs),

    // ── the control plane, over the wire ───────────────────────────────────
    /// Configure a RUNNING gunnar over its gRPC control plane.
    ///
    /// The first call needs no credential at all; every one after it is
    /// authenticated with an ssh key. `gunnar control status` says which of
    /// those a server is in.
    ///
    /// These are the ONLINE verbs. `gunnar principal`, `gunnar key` and
    /// `gunnar grant` write the control repository directly and therefore need
    /// the server to be DOWN; these do the same work against one that is up.
    #[cfg(feature = "control")]
    #[command(subcommand)]
    Control(ControlCommand),

    /// **Log this terminal in with your passkey, through a console.**
    ///
    /// Mints a fresh ed25519 key here, shows a code, and waits: open the
    /// console's `/login/device` page, type the code and touch your passkey.
    /// Gunnar enrols THIS key on you, expiring (at most a day); its private
    /// half never leaves this machine. Use it with `-i <path>` (printed).
    /// The other CLI way in is a FIDO2 `sk-ssh-ed25519` key in ssh-agent.
    #[cfg(feature = "control")]
    Login(LoginArgs),

    // ── systemd ────────────────────────────────────────────────────────────
    /// Manage gunnar as a systemd service. Off unless built with `--features systemd`.
    #[cfg(feature = "systemd")]
    #[command(subcommand)]
    Service(ServiceCommand),
}

/// `gunnar login`, see [`Command::Login`].
#[cfg(feature = "control")]
#[derive(Debug, clap::Args)]
pub struct LoginArgs {
    /// The console's address, e.g. `https://console.example.com`.
    pub console: String,
    /// How long the key lives, in hours (the console allows at most 24).
    #[arg(long, default_value_t = 12)]
    pub ttl: u64,
    /// What the approval page calls this terminal (default: `user@host`).
    #[arg(long)]
    pub label: Option<String>,
    /// Where the key is written (default: `~/.config/gunnar/login`).
    #[arg(long)]
    pub dir: Option<PathBuf>,
}

// ───────────────────────────────────────────────────────────────────────────
// server
// ───────────────────────────────────────────────────────────────────────────

/// `--anonymous`, see [`ServeArgs::anonymous`].
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum AnonymousArg {
    /// Serve anonymous reads of public repositories on this box.
    Allow,
    /// Refuse them, naming `--browse-host` when given.
    Refuse,
}

/// `gunnar serve --role`.
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ServeRole {
    /// Serves git; frames every transaction for its twin.
    Primary,
    /// Serves no git; holds the primary's log.
    Twin,
}

/// `gunnar serve`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args, Clone)]
pub struct ServeArgs {
    /// Where the SSH listener binds. Publickey auth, so binding publicly is the
    /// intended configuration.
    #[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
    pub ssh_bind: std::net::SocketAddr,

    /// Where the gRPC control plane binds — `<addr>:<port>`, or
    /// `<interface>:<port>` for an address learned at boot.
    ///
    /// **Loopback by default and it should stay there.** gRPC now requires an
    /// `sshsig` credential from a key in gunnar's identity table and a grant from
    /// the same ACL as SSH (the allow-all hole was closed 2026-08-03), but the
    /// loopback default is defence in depth and costs nothing: every real
    /// deployment reaches this port over an SSH tunnel or from the same host
    /// anyway.
    ///
    /// **`0.0.0.0` is not the way to reach it from another host.** A cloud
    /// appliance has a PUBLIC interface and a private one, and a wildcard
    /// binds both — which is how this control plane ended up answering
    /// `repo list` and `repo create` to an uncredentialled caller on the open
    /// internet (measured 2026-08-31). Name the private interface instead:
    /// `--grpc-bind eth1:50051`. Its address is not knowable when the medium
    /// is mastered — DHCP decides it, per box, per lease — and the interface
    /// is. See [`gunnar_common::bindspec::BindSpec`].
    #[arg(
        long,
        default_value = "127.0.0.1:50051",
        value_name = "ADDR|IFACE:PORT"
    )]
    pub grpc_bind: crate::bindspec::BindSpec,

    /// An OpenSSH public key line, or `@<path>` to read one from a file, to
    /// install as the bootstrap admin when the identity table is empty.
    ///
    /// Without it, an empty table makes gunnar **refuse to serve** rather than
    /// start unreachable.
    #[arg(long, value_name = "KEY|@FILE")]
    pub admin_key: Option<String>,

    /// **A key this box may take a `Bootstrap.Restore` from**: an OpenSSH
    /// public key line, or `@<path>` to read one. Repeat the flag for more;
    /// `GUNNAR_RESTORE_PUBKEY` takes several separated by `,`.
    ///
    /// A restore stream decides a fresh box's KEK, its admin, its host key and
    /// its issuer, and a fresh box has no identity table to check its sender
    /// against, so it takes one only under a credential signed by a key it was
    /// FED: this flag, or — on an installer-fed box — the fed `admin_pubkey`
    /// and admin principals. A box with neither refuses every restore by name
    /// (`restore-no-key`).
    #[arg(
        long = "restore-pubkey",
        value_name = "KEY|@FILE",
        env = "GUNNAR_RESTORE_PUBKEY",
        value_delimiter = ','
    )]
    pub restore_pubkeys: Vec<String>,

    /// Do not start the gRPC listener. The SSH listener still runs.
    #[arg(long)]
    pub no_grpc: bool,

    /// **WireGuard-only** (owner, 2026-10-04): every box-to-box connection
    /// runs over the estate's overlay. The control plane's bound address (the
    /// utility network, `eth1:50051`) serves nothing; the control plane and the
    /// replication listener answer only on the tunnel's address, and SSH drops
    /// a connection that arrives at the utility address. A plain-IP connection
    /// between two boxes is refused.
    #[arg(long = "wg-only", env = "GUNNAR_WG_ONLY")]
    pub wg_only: bool,

    /// **What this box is.** `primary` (the default) serves git and frames
    /// every transaction. `twin` serves NO git from its first boot (the forge
    /// listener is bound to loopback only), waits for `Bootstrap.BecomeTwin`,
    /// and then follows its primary's log (`.nornir/twin-transaction-log.md`).
    /// **A twin is not a different medium.** Until 2026-09-17 a `gunnar-twin`
    /// ISO welded `--role twin` into its `korp.args=`, which made the role part
    /// of a medium's identity and — because that medium was cloned from the
    /// KEYLESS row — made gunnar.rs's twin an UNMETERED binary that would have
    /// served every suspended tenant the moment it was promoted. Since 2026-09-22
    /// the medium does not say what a box is at all (no `role` install parameter,
    /// no `GUNNAR_ROLE` env read): every box installs as a fresh appliance and a
    /// twin is made one over the wire by `Bootstrap.BecomeTwin`, which writes
    /// `twin.json` — the durable role `stores.is_twin()` reads. This argv flag
    /// stays for a directly started server (tests, benches); an installed box
    /// never passes it.
    #[arg(long, value_enum, default_value_t = ServeRole::Primary)]
    pub role: ServeRole,

    /// Refuse unauthenticated callers entirely, even for repositories marked
    /// `--public`.
    ///
    /// By default a public repository can be cloned with no key at all, over
    /// `ssh://anonymous@host/ns/repo.git`. This closes that door at the
    /// listener, before any repository is resolved, for an appliance that has
    /// to be able to state it has never served a stranger. It is **not** what
    /// protects private repositories: those are protected by the ACL and are
    /// unreachable anonymously either way.
    #[arg(long)]
    pub no_anonymous: bool,

    /// **Where anonymous reads are served** — the browse replica. `allow`
    /// (the default) serves a stranger's clone of a public repository here.
    /// `refuse` sends every anonymous request away with a line that names the
    /// browse replica (`--browse-host`) it is served by: strangers cost CPU and
    /// bandwidth on the box that also takes the authenticated writes, and the
    /// browse replica is where that cost belongs. Unlike `--no-anonymous`, which
    /// merely fails the stranger's authentication, `refuse` tells them where
    /// to go. A box whose role is BROWSE serves anonymous reads whatever this
    /// says — it is the box the knob points at.
    #[arg(long, value_enum, default_value_t = AnonymousArg::Allow, env = "GUNNAR_ANONYMOUS")]
    pub anonymous: AnonymousArg,

    /// The browse replica's `host[:port]`, named in the `--anonymous refuse`
    /// line (`clone <repo> from <host>`). Optional: without it the refusal
    /// says only that anonymous reads are not served here.
    #[arg(long, value_name = "HOST[:PORT]", env = "GUNNAR_BROWSE_HOST")]
    pub browse_host: Option<String>,

    /// **How much of the store root's disk must stay free** before a push or
    /// a `Repos.Create` is refused — the community plan gate, the "free lunch"
    /// (ruled 2026-09-04). Terms separated by commas: a percentage (`5%`)
    /// and/or a byte count (`2GiB`, `500M`, `4096`); the LARGER of the two is
    /// the floor. The default keeps 5 % or 2 GiB, whichever is more: 5 % is the
    /// reserve the box needs to keep operating (gc scratch, control-store
    /// commits, `replication.json` rewrites) and mirrors ext4's own reserved
    /// blocks; 2 GiB is the absolute floor for a small disk where 5 % is less
    /// than one pack. A push under the floor is refused BEFORE any pack byte,
    /// naming the free bytes and the floor; reads are never refused. Measured
    /// with `statvfs`, no `df`. `0%,0` is refused by name: a gate that can never
    /// refuse is not a gate.
    #[arg(
        long,
        value_name = "SPEC",
        default_value = "5%,2GiB",
        env = "GUNNAR_DISK_FLOOR"
    )]
    pub disk_floor: String,
    /// **Do not enumerate a push's object ids for the attestation.**
    ///
    /// Enumeration waits for the indexer to absorb the pack, which is a drain
    /// on the push path, so an operator who does not need the ids in the record
    /// can decline them. Declining does NOT restore silence: the attestation
    /// then carries the reason the set is absent
    /// (`PushAttestation.new_object_oids_unknown`), because an absence that
    /// renders as an empty list is the defect this flag exists beside, not the
    /// behaviour it restores.
    ///
    /// Named in the negative on purpose: the complete attestation is what you
    /// get by forgetting, and the cheap one is what you opt into. A push that a
    /// policy gate reads is enumerated either way — the gate needs the set.
    #[arg(long)]
    pub no_attest_objects: bool,

    /// How many git transfers may be in flight across the whole server (the
    /// admission gate). Default: DERIVED from the box — cores x 16, capped by
    /// the descriptors the handle cache has not spoken for, floor 8, ceiling
    /// 256.
    ///
    /// The server says what it derived, and what from, in one INFO row at
    /// start: `transfers 64 · open repos 204 · git memory 3.9G (cores 4,
    /// fds 1048576, MemAvailable 7.8G)`. Setting this replaces the derivation
    /// exactly — the number is never raised and never lowered.
    #[arg(long, value_name = "N", env = "GUNNAR_MAX_TRANSFERS")]
    pub max_concurrent_transfers: Option<usize>,
    /// The memory budget for git operations in flight, in bytes — the
    /// admission gate's second budget. Default: half of `MemAvailable` at
    /// start.
    ///
    /// The ledger charges each transfer a small entry cost and then what its
    /// pack actually moves, so this is a budget rather than a second
    /// concurrency cap. It used to be a compiled-in 8 GiB against a flat
    /// 320 MiB per push, which made "25 concurrent pushes" the real ceiling on
    /// every box regardless of its RAM.
    #[arg(long, value_name = "BYTES", env = "GUNNAR_GIT_MEMORY_BYTES")]
    pub git_memory_bytes: Option<u64>,

    /// How long a transfer may WAIT for a slot before it is refused, in
    /// milliseconds. Default 2000; `0` refuses on the spot.
    ///
    /// A refused client reconnects, and a reconnect is a whole SSH handshake —
    /// more than the wait costs. The wait is bounded, counted, and is what the
    /// delay governor is driven by.
    #[arg(long, value_name = "MS", env = "GUNNAR_ADMISSION_WAIT_MS")]
    pub admission_wait_ms: Option<u64>,

    /// Close a git transfer that moves NO bytes either way for this many seconds while
    /// it waits on the client, and give its slot back. Default 180; `0` turns it off.
    ///
    /// The connection's own timeouts never catch it: russh resets the inactivity timer
    /// on every keepalive reply, so a live client that sends nothing (a stuck
    /// `git-lfs-transfer`) held its slot for good — 35 minutes of a key's 32 on
    /// git.gunnar.rs, 2026-11-01. Server-side work (building a pack) is never timed.
    #[arg(long, value_name = "SECS", env = "GUNNAR_TRANSFER_IDLE_SECS")]
    pub transfer_idle_secs: Option<u64>,

    /// What one identity is **guaranteed** at once, and its share of a
    /// contended server. Default 32.
    ///
    /// A floor, not a wall: on a server that is not near saturation a single
    /// identity may use every slot `--max-concurrent-transfers` allows. Once
    /// the server is contended each active identity gets an equal share of it,
    /// never below this number.
    #[arg(long, value_name = "N")]
    pub max_transfers_per_principal: Option<usize>,

    /// How many git reads are served **without a key** at once.
    /// Default 5.
    ///
    /// The companion to `--anonymous`: that decides whether the door is open,
    /// this decides how wide. Unlike `--max-transfers-per-principal` it is a
    /// wall and not a floor — it binds on an idle server, because the caller it
    /// bounds cannot be named, revoked or telephoned, and "the box was quiet so
    /// I let a stranger have all of it" is not a trade an operator would make
    /// deliberately.
    ///
    /// Raise it on a box whose *purpose* is public serving (a browse replica);
    /// `0` refuses every anonymous read while leaving the door itself open,
    /// which is the arm to use when you want the refusal to say so rather than
    /// have `--anonymous refuse` deny the repository exists.
    #[arg(long, value_name = "N", env = "GUNNAR_MAX_ANONYMOUS_TRANSFERS")]
    pub max_anonymous_transfers: Option<usize>,

    /// **The usage share's budget**: bytes (in + out) a namespace may move per
    /// window at the ordinary per-principal share. Past it the namespace is
    /// served at the ANONYMOUS ceiling (`--max-anonymous-transfers`) and
    /// refused fast with its own reason — never queued. `none` turns the
    /// share off (PassThrough: admission decides as a build without it).
    /// SI or IEC sizes: `100 GB`, `5 GiB`, or bytes. Default 100 GB.
    ///
    /// Only in a build with the `admit-share` feature.
    #[cfg(feature = "admit-share")]
    #[arg(long, value_name = "SIZE|none", env = "GUNNAR_ADMIT_LIMIT")]
    pub admit_limit: Option<String>,

    /// The usage share's rolling window, in UTC days, today included.
    /// Default 30.
    ///
    /// Only in a build with the `admit-share` feature.
    #[cfg(feature = "admit-share")]
    #[arg(long, value_name = "DAYS", env = "GUNNAR_ADMIT_WINDOW_DAYS")]
    pub admit_window_days: Option<u32>,

    /// The largest object `Refs.ReadObject` will read, in bytes.
    /// Default 67108864 (64 MiB); `0` means no ceiling.
    ///
    /// Reading any part of an object costs the WHOLE object in memory — a
    /// prefix of a delta-encoded object needs its entire base chain — so
    /// `max_bytes` bounds the answer and not the work. This bounds the work.
    /// The default is a 512 MiB budget divided by the 8 control-plane workers
    /// that can be inflating at once; raise it on a box whose repositories hold
    /// large blobs and whose control port is trusted, and note that the git
    /// clone path is untouched by this and streams either way.
    #[arg(long, value_name = "BYTES")]
    pub max_object_bytes: Option<u64>,

    /// **The metered appliance's key**: monetize-server's Ed25519 public key,
    /// either spelled inline as its 64 hexadecimal characters or as the path of
    /// a file holding it (32 raw bytes, 64 hex characters, or
    /// `ed25519:<base64>`). Every `Entitlement.Set` is verified against it
    /// before a plan moves. Only a `metered` build honours it; any other build
    /// REFUSES to start when it is set, by name — an operator who configured a
    /// metered appliance on a binary that cannot meter must find out at boot,
    /// not from a tenant who was never suspended.
    ///
    /// **This flag is NOT compiled out of the open build, and that is the
    /// point.** It is the one piece of the monetize seam an open gunnar must
    /// still RECOGNISE, because a flag clap has never heard of is refused as
    /// "unexpected argument" — a parser complaint that says nothing about why
    /// this binary cannot do what was asked. Kept, the open build answers with
    /// the sentence in `gunnar_server::dispatch` instead: this build contains
    /// no entitlement code at all and would serve every namespace Free.
    ///
    /// **Which of the two it is, is decided by SHAPE — there is no second
    /// flag.** A value with no `/` in it that is either all hexadecimal or
    /// exactly 64 characters long is the KEY; anything else is a PATH. So
    /// `/data/monetize.pub` and `monetize.pub` are files, as they always were,
    /// and `43cb…8428` is the key itself. A near-miss (63 characters, or a
    /// typo that is not a hex digit) is refused as a malformed key naming what
    /// was wrong, rather than silently becoming a filename nothing will find.
    ///
    /// Inline is what an appliance actually needs. The installer only stages
    /// files onto `/data` under `korp.reinstall=wipe`, the ESP is read-only at
    /// runtime, and so on a keep-upgrade the kernel command line is the only
    /// writable channel into a booting gunnar — a path there names a file
    /// nothing was allowed to create. An Ed25519 *public* key is safe to carry
    /// that way; a private key never would be.
    #[arg(long, value_name = "HEX_OR_PATH", env = "GUNNAR_MONETIZE_PUBKEY")]
    pub monetize_pubkey: Option<crate::monetize_key::MonetizePubkey>,

    // ── WHY THIS ONE IS COMPILED OUT AND `--monetize-pubkey` IS NOT ────────
    //
    // Kept as a plain comment rather than a doc comment ON PURPOSE: clap turns
    // `///` into `--help`, and the explanation below is for whoever reads this
    // file, not for an operator of the open build — writing it as documentation
    // would reintroduce, in prose, exactly the vocabulary this gate removes.
    //
    // gunnar is the OPEN half of the split: it reports `Usage`, it receives a
    // signed `Entitlement`, and it takes no money ever. The private monetize
    // half is the only thing that knows what a byte is worth. So an open
    // `gunnar-server --help` must not advertise a flag whose whole noun is a
    // payment page — and until this `cfg`, it did, for a field the open binary
    // has no code to honour.
    //
    // `--monetize-pubkey` three declarations up is deliberately NOT gated, and
    // the asymmetry is the argument, not an oversight. That flag's PRESENCE
    // changes what the box would do — meter, or silently serve every namespace
    // Free — so an open build has to recognise it in order to refuse it by
    // name at boot. This flag cannot even be given without it: `requires`
    // sees to that, so an open build handed both stops on the pubkey's
    // sentence either way, and one handed this alone was already going to be
    // refused. Compiling it out therefore costs no refusal that the pubkey's
    // own does not already carry, and buys a `--help` with no money in it.
    //
    // And there is NO DEFAULT, here or at the use site. The fallback used to
    // live in `gunnar_server::dispatch` as Vetra's own commercial URL, which
    // meant anybody building a metered gunnar and forgetting this flag pointed
    // their tenants at somebody else's payment page. A metered build given
    // `--monetize-pubkey` without this now refuses at boot; see
    // `gunnar_server::entitlement::MeteredConfig::load`.
    /// **Where a lapsed tenant goes to restore its plan**, as a template with
    /// `{tenant}` for the namespace (without the placeholder, `/{tenant}` is
    /// appended, so the link always names the tenant). Printed in every
    /// refusal and Grace warning. Requires `--monetize-pubkey`, and a
    /// `metered` build that is given that flag without this one refuses to
    /// start: a page belonging to whoever built the binary is not a default
    /// anybody else's tenants should be sent to.
    #[cfg(feature = "metered")]
    //
    // No clap `requires = "monetize_pubkey"` (2026-09-24): the key may come from the
    // installer's door (`gunnar_server::feed`) instead of this argv, so a one-medium
    // metered box carries the pay page and no key. The same refusal — this flag with
    // no key from EITHER source — is made in `gunnar_server::dispatch`.
    #[arg(long, value_name = "URL", env = "GUNNAR_BILLING_URL")]
    pub billing_url: Option<String>,

    /// **Is monetize VITAL to this box?** Owner ruling 2026-09-22. `true`, the default: stored
    /// entitlement facts that do not verify at boot (a main restored under another monetize key)
    /// raise the `Admin.Health` alarm `entitlement.key_mismatch`, naming the namespaces, and the
    /// box reports status `maintenance` until monetize pushes a fact that verifies. The box
    /// still starts and serves either way, and gunnar never dials monetize. `false`: the same
    /// refusal is only logged.
    #[cfg(feature = "metered")]
    #[arg(long, value_name = "BOOL", env = "GUNNAR_MONETIZE_VITAL", default_value_t = true, action = clap::ArgAction::Set)]
    pub monetize_vital: bool,

    /// Make `--max-transfers-per-principal` an unconditional ceiling.
    ///
    /// For a host where an identity is a *tenant* and the cap is how the
    /// box is shared out. On an appliance — one team, a handful of keys — this only
    /// throttles the owner, which is why it is off by default.
    #[arg(long)]
    pub hard_per_principal_limit: bool,

    /// Serve the **counters** on this address, as Prometheus text.
    ///
    /// Off unless asked for, and there is no default address: nothing binds
    /// until you name one. `curl http://<addr>/metrics` then returns the same
    /// nineteen numbers the `gunnar::metrics` heartbeat writes to the log —
    /// but on demand, at whatever rate you ask, which is the only way
    /// `gunnar_transfers_in_flight` tells a slow transfer from a stuck one
    /// while it is still running.
    ///
    /// **It has no authentication.** The body is nineteen integers about this
    /// process — no repository names, no principals, no references — so it is
    /// not worth a credential, but anyone who can reach the port learns how
    /// busy this server is. Bind it to `127.0.0.1` and let the scraper come
    /// over a tunnel unless you mean otherwise; a non-loopback address is
    /// accepted and logged as a `warn` at start-up.
    #[arg(long, value_name = "ADDR")]
    pub metrics_bind: Option<std::net::SocketAddr>,

    /// **Watch this primary and TAKE OVER if it stops answering** — I11's
    /// passive heartbeat, the standby half of failover.
    ///
    /// Off unless you name an address, and that is the design rather than
    /// caution: naming a new primary is a decision, so a box never makes it
    /// because nobody configured it not to. Give the PRIMARY's control-plane
    /// address; this box probes it every `--watch-probe-ms` and, after
    /// `--watch-promote-after` **consecutive** failures, does to itself exactly
    /// what `gunnar control replication promote` does — persists `role:
    /// primary` and thaws the write freeze. One reachable probe resets the run,
    /// so a flapping link cannot add up to a takeover.
    ///
    /// It does nothing on a box whose role is `primary`: the watch re-reads the
    /// role every tick and stops when this box is not a standby, so putting the
    /// flag on BOTH boxes of a pair is safe and is what one unit file wants.
    ///
    /// **What the probe proves:** a TCP connect to that address, i.e. *"is
    /// something listening"* — not *"is it serving git"*. A primary that is
    /// wedged but still listening reads as alive and this box will NOT promote.
    /// That is the safe direction to be wrong in: the cost is a manual
    /// `replication promote`, where the other way round is two writable boxes.
    ///
    /// **The address still has to move.** Taking over makes this box writable;
    /// it does not repoint the front (DNS / the UpCloud DNAT). That is the one
    /// infrastructure step failover has always had.
    #[arg(long, value_name = "ADDR")]
    pub watch_primary: Option<std::net::SocketAddr>,

    /// Milliseconds between probes of `--watch-primary`. Ignored without it.
    #[arg(long, value_name = "MS", default_value_t = 5_000)]
    pub watch_probe_ms: u64,

    /// Consecutive failed probes before this box promotes itself. Ignored
    /// without `--watch-primary`; a zero is read as one.
    ///
    /// The default is six, which at the default interval is **thirty seconds of
    /// continuous unreachability** — longer than a restart of the primary, and
    /// shorter than a coffee break.
    #[arg(long, value_name = "N", default_value_t = 6)]
    pub watch_promote_after: u32,

    /// Milliseconds one connect attempt may take before it counts as a failure.
    /// Ignored without `--watch-primary`.
    ///
    /// Kept below `--watch-probe-ms` deliberately: a connect that outlives the
    /// interval lets probes overlap, and one dead primary becomes a growing
    /// pile of pending sockets.
    #[arg(long, value_name = "MS", default_value_t = 2_000)]
    pub watch_connect_ms: u64,

    /// **Auto-fix** (5.4 resilience): when this box comes up with its data set
    /// dead or empty while a healthy twin exists, recharge it from the twin by
    /// itself. OFF by default: without it the box only SAYS `data lost; recharge
    /// from twin available`, and an admin's [Recharge from twin] or a signed
    /// guardian order does it. The estate's opt-in, like `--watch-primary`.
    #[arg(long, env = "GUNNAR_REPAIR_AUTO")]
    pub repair_auto: bool,

    /// The OpenSSH private key this box signs repair calls with (reading the
    /// twin's log, `Bootstrap.Restore` into a fresh main, fencing a primary).
    /// Default: this box's twin key, when it has one. A fresh main takes a
    /// restore only from a key it was fed (`--restore-pubkey`).
    #[arg(long, value_name = "PATH", env = "GUNNAR_REPAIR_KEY")]
    pub repair_key: Option<std::path::PathBuf>,

    /// The twin the boot check reviews and recharges from (`host:port` of its
    /// control plane). Default: the twin the installer's re-admit named; a main
    /// that grows also asks the twin in the door's peer list.
    #[arg(long, value_name = "ADDR", env = "GUNNAR_REPAIR_TWIN")]
    pub repair_twin: Option<String>,

    /// **The pull channel**: monetize's `Fleet` endpoint this box reports to and
    /// pulls signed repair orders from (`https://…`). Off when absent. Monetize
    /// never calls into the box; every order is verified under the monetize key.
    #[arg(long, value_name = "URL", env = "GUNNAR_FLEET")]
    pub fleet: Option<String>,

    /// PEM of the CA monetize's `Fleet` certificate is signed by.
    #[arg(long, value_name = "PATH", env = "GUNNAR_FLEET_CA")]
    pub fleet_ca: Option<std::path::PathBuf>,

    /// The estate (monetize instance) this box belongs to. Required with `--fleet`.
    #[arg(long, value_name = "NAME", env = "GUNNAR_FLEET_INSTANCE")]
    pub fleet_instance: Option<String>,

    /// The role this box pulls as (`main` / `twin`). Default: what the box is.
    #[arg(long, value_name = "ROLE", env = "GUNNAR_FLEET_ROLE")]
    pub fleet_role: Option<String>,

    /// The longest pause between two reports, in seconds.
    #[arg(long, value_name = "S", default_value_t = 60)]
    pub fleet_every_s: u64,

    /// Also serve **smart HTTP** on this address.
    ///
    /// Off unless asked for. `git clone http://host/ns/repo.git` then works,
    /// and so does `push`, with an access token as the HTTP Basic password
    /// (`gunnar token add`). The same repositories, the same grants and the
    /// same `--no-anonymous` switch as SSH: this is a second transport, not a
    /// second server.
    #[cfg(feature = "http")]
    #[arg(long, value_name = "ADDR")]
    pub http_bind: Option<std::net::SocketAddr>,

    /// Accept an access token over a **plain** HTTP connection.
    ///
    /// gunnar terminates no TLS. By default it therefore refuses to read a
    /// credential unless a terminator in front of it says the client spoke TLS
    /// (`X-Forwarded-Proto: https`), because a token in an `Authorization`
    /// header on a cleartext connection is a password on the wire. This turns
    /// that off, for loopback, a test, or a link you vouch for.
    ///
    /// **Anonymous read is unaffected either way**: it presents no credential.
    #[cfg(feature = "http")]
    #[arg(long)]
    pub http_allow_cleartext_credentials: bool,

    /// Terminate **TLS on the smart-HTTP listener itself**, with a certificate
    /// gunnar mints from its own issuer at start — the single-executable
    /// deployment: no proxy, no PEM files, `git clone https://host/ns/repo.git`
    /// against one binary. Clients pin the issuer (`gunnar issuer show --pem`)
    /// exactly as they do for the client certificates it already mints.
    ///
    /// ALPN offers HTTP/2 then HTTP/1.1, so a client that multiplexes gets to.
    /// Mutually exclusive with `--http-tls-cert`/`--http-tls-key`, which is the
    /// bring-your-own-certificate form of the same listener.
    #[cfg(feature = "http")]
    #[arg(long, conflicts_with_all = ["http_tls_cert", "http_tls_key"])]
    pub http_tls_self: bool,

    /// Terminate TLS on the smart-HTTP listener with this certificate chain
    /// (PEM, leaf first). Needs `--http-tls-key`.
    #[cfg(feature = "http")]
    #[arg(long, value_name = "PEM", requires = "http_tls_key")]
    pub http_tls_cert: Option<std::path::PathBuf>,

    /// The private key for `--http-tls-cert` (PEM).
    #[cfg(feature = "http")]
    #[arg(long, value_name = "PEM", requires = "http_tls_cert")]
    pub http_tls_key: Option<std::path::PathBuf>,

    /// Require a **client certificate** on the gRPC control plane, and mint the
    /// listener's own identity from this server's issuer at start.
    ///
    /// This is what makes the control plane safe to bind off loopback. Two
    /// things change together: a peer whose certificate this issuer did not
    /// sign never finishes a handshake, and a certificate that does get through
    /// is looked up in the identity key table — so one the issuer signed and nobody
    /// registered is still refused, by the gate, with an audit record.
    ///
    /// Clients get their certificate from `gunnar control bootstrap` (which
    /// writes `<name>.cert.pem`, `<name>.key.pem` and `gunnar-issuer.pem` to
    /// `~/.config/gunnar/<server>/`, or `--out`) and
    /// present it with `--client-cert`/`--client-key`/`--ca`.
    ///
    /// Off by default: turning it on rewrites how every existing client
    /// connects, and a default that breaks a working deployment gets turned
    /// back off everywhere.
    ///
    /// **It cannot be the first thing a server does.** A mutual listener
    /// refuses every peer with no certificate, and on a server that has never
    /// been enrolled there is no certificate anywhere — including for
    /// `Bootstrap.Enroll`, the one RPC served without a credential, which is
    /// behind the same handshake. `gunnar serve` REFUSES to start in that
    /// state and names the enrolment steps. Every other unhappy state is a
    /// warning naming the principals that lose access; run
    /// `gunnar issuer readiness` first to see them before the restart, and
    /// `gunnar control health` afterwards to confirm the flip took.
    #[arg(long)]
    pub grpc_tls_self: bool,

    /// **The mutual replication listener a twin follows this box through.**
    ///
    /// Mutual TLS 1.3 only: a peer must present a certificate this server's
    /// issuer signed (`gunnar control twin certify` gets a twin one). Unset, it is
    /// DERIVED — the `--grpc-bind` address, port + 1, whenever that address is
    /// off loopback — so the appliance serves it with no flag on its medium, and
    /// a loopback control plane serves none.
    #[arg(long, value_name = "ADDR")]
    pub grpc_replication_bind: Option<std::net::SocketAddr>,
    /// **Accepted and ignored since 5.3.3.** The method-blind
    /// `gunnar-sshsig-v1` credential is refused on every listener, always
    /// (GUN-001; OWNER 2026-10-10), so there is nothing left for this to turn
    /// on — and nothing that turns it off. Kept, hidden, only so a unit file
    /// that still passes it starts instead of failing to parse.
    #[arg(long, hide = true)]
    pub grpc_require_bound_credentials: bool,

    /// **A WebAuthn relying party this server answers for**:
    /// `<rp_id>=<origin>[,<origin>…]`, repeatable (stinky row 13, 2026-10-10).
    ///
    /// Without one, a passkey ceremony's `rp_id` and `origin` are the relay's
    /// word, as before. With any, a ceremony for another relying party, or from
    /// an origin not listed, is refused — so a relay cannot run one for a
    /// relying party it merely names.
    #[arg(long = "web-relying-party", value_name = "RP_ID=ORIGIN[,ORIGIN]")]
    pub web_relying_party: Vec<String>,

    /// **Every passkey ceremony must verify the user** (a PIN or a biometric,
    /// not a touch): web logins and the passkey after a company sign-in alike.
    /// The server decides; a relay may ask for more, never for less (A34-12).
    /// Off: a touch is enough unless the relay asks.
    #[arg(long = "web-require-user-verification")]
    pub web_require_user_verification: bool,
    /// **Anybody may sign up on this site** (IDENTITY-HOST): the identity core's Root
    /// site, where anybody may sign up; without it the site is Private (invite
    /// only), the default.
    #[arg(long = "identity-root-site")]
    pub identity_root_site: bool,

    /// Do not offer `filter`, so this server serves no partial clones.
    ///
    /// Removes `filter` from the advertisement **and** makes the parser refuse
    /// the argument, in both protocol versions, so a client cannot get a
    /// filtered pack by asking for one anyway. `git clone --filter=blob:none`
    /// then fails with *"this server does not offer the filter capability"*
    /// rather than quietly serving the whole thing.
    ///
    /// For an appliance that has to be able to state it never serves arbitrary
    /// blob subsets. Everything else about a clone is unchanged.
    #[arg(long)]
    pub no_filter: bool,

    /// Which object ids a client may `want`. Default `reachable`.
    ///
    /// `advertised-tips` is the strictest and **breaks partial clone**: a
    /// `blob:none` clone's lazy fetch asks for bare blob ids that were never
    /// advertised, so every `git checkout` in one becomes "the remote end hung
    /// up unexpectedly". Pair it with `--no-filter` or not at all. `any` makes
    /// every object ever pushed fetchable by whoever can name it, including on
    /// deleted branches.
    #[arg(long, value_name = "POLICY", value_enum)]
    pub want_policy: Option<WantPolicyArg>,

    /// Ceiling on **objects emitted** while answering one fetch.
    ///
    /// An operator's explicit cap on the size of a clone. It is no longer what
    /// bounds gunnar's memory — `--max-fetch-walk-bytes` is, in the unit a
    /// machine is actually sized in — and the reason is that an object count
    /// does not convert to bytes at a fixed rate: MEASURED across gunnar's
    /// corpus on 2026-08-05 the same walk cost between 393.8 and 1 572.9 bytes
    /// per emitted object, a 4x spread set by the repository's shape.
    ///
    /// There is no "off" value. `0` would refuse every fetch rather than allow
    /// every fetch — the walk stops when it reaches the budget — so it is
    /// rejected at startup instead of being accepted as an unbounded server
    /// that in fact serves nothing.
    #[arg(long, value_name = "N")]
    pub fetch_budget: Option<usize>,

    /// Bytes of object bookkeeping **one fetch** may hold. `0` removes the
    /// ceiling. Default 8589934592 (8 GiB).
    ///
    /// Charged at a measured 384 bytes per object the walk is holding, so the
    /// default admits about 22.4 million objects. That is deliberately past the
    /// linux kernel's 13 816 503: MEASURED 2026-08-05, a 14 042 000-object
    /// repository with no `.bitmap` clones in 5 460 816 kB of peak RSS and 64
    /// seconds, and the object-counted ceiling it shipped with refused it.
    ///
    /// This is a **per-request** number and on its own it is not a bound on the
    /// server: multiply it by `--max-concurrent-transfers`. See
    /// `--max-walk-bytes-total`.
    #[arg(long, value_name = "BYTES")]
    pub max_fetch_walk_bytes: Option<usize>,

    /// Bytes of object bookkeeping **the whole server** may hold at once,
    /// shared by every transfer in flight. `0` removes the ceiling. Defaults to
    /// half of this machine's `MemTotal`.
    ///
    /// The ceiling that actually bounds the box. `--max-concurrent-transfers`
    /// counts transfers, which says nothing about memory: 256 concurrent clones
    /// of a kernel-sized repository need well over a terabyte between them, and
    /// what the shipped server did about that was let the OOM killer choose.
    /// A transfer that cannot get its reservation is refused with a sentence
    /// naming both numbers, and the client can retry.
    ///
    /// A walk holding under 8 MiB never touches the pool, so ordinary clones
    /// neither queue nor draw on it.
    #[arg(long, value_name = "BYTES")]
    pub max_walk_bytes_total: Option<usize>,

    /// Bytes of pkt-line payload held for one peer-supplied request. `0`
    /// removes the ceiling. Default 8388608 (8 MiB).
    ///
    /// The size is the **peer's** to choose and costs it about what it costs
    /// the server, which is why this is an absolute number.
    #[arg(long, value_name = "BYTES")]
    pub max_fetch_request_bytes: Option<usize>,

    /// Bytes of a single object gunnar will put in a pack. `0` removes the
    /// ceiling. Default 1073741824 (1 GiB), the same number `gunnar-pack`
    /// applies on the way in.
    #[arg(long, value_name = "BYTES")]
    pub max_fetch_object_bytes: Option<usize>,

    /// Largest LFS object this server will store, in bytes. Default
    /// 8589934592 (8 GiB).
    ///
    /// **There is no "off" value**, and `0` is refused at startup. Unlike the
    /// two fetch ceilings above, `gunnar_lfs::Limits` holds a plain `u64` and
    /// not an `Option`, so `0` means "refuse every upload" rather than "accept
    /// any size". A finite default is deliberate: "no limit" means one client
    /// decides how much disk the server has.
    #[arg(long, value_name = "BYTES")]
    pub max_lfs_object_bytes: Option<u64>,

    /// Most objects one LFS `batch` request may name. Default 10000. `0` is
    /// refused, for the same reason as above.
    #[arg(long, value_name = "N")]
    pub max_lfs_batch_objects: Option<usize>,

    /// Bytes of a refused LFS upload that will be read and discarded to keep
    /// the channel in sync before the session is dropped. Default 131056
    /// (two pkt-line payloads). `0` is refused.
    ///
    /// A refusal cannot simply stop reading: the client is already writing the
    /// object into the channel and the framing only ends at its flush-pkt, so a
    /// server that answered 413 and then read the next *command* would read
    /// object bytes instead.
    #[arg(long, value_name = "BYTES")]
    pub max_lfs_drain_bytes: Option<u64>,

    /// Bind, report, and exit rather than serving. For smoke tests and for
    /// checking a configuration without holding the ports.
    #[arg(long)]
    pub check: bool,
}

/// `--want-policy`, one arm per [`gunnar_wire::WantPolicy`].
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum WantPolicyArg {
    /// Only ids that appeared in the advertisement. Breaks partial clone.
    AdvertisedTips,
    /// Anything reachable from a reference. The default.
    Reachable,
    /// Anything in the store, reachable or not.
    Any,
}

#[cfg(feature = "server")]
impl WantPolicyArg {
    /// The wire's own enum. One translation, in one place, so the flag and the
    /// behaviour cannot drift (one implementation).
    pub fn to_wire(self) -> gunnar_wire::WantPolicy {
        match self {
            WantPolicyArg::AdvertisedTips => gunnar_wire::WantPolicy::AdvertisedTips,
            WantPolicyArg::Reachable => gunnar_wire::WantPolicy::ReachableFromRefs,
            WantPolicyArg::Any => gunnar_wire::WantPolicy::Any,
        }
    }
}

/// Namespace verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum NamespaceCommand {
    /// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
    /// inherit between them.
    Create {
        /// The namespace name.
        name: String,
    },
    /// List namespaces.
    List,
    /// Delete a namespace.
    Delete {
        /// The namespace name.
        name: String,
        /// Also delete every repository in it. Never implicit.
        #[arg(long)]
        delete_repos: bool,
    },
    /// Set this account's read-cache budget.
    #[command(alias = "set-quota")]
    SetBudget {
        /// The namespace name.
        name: String,
        /// **The exploded read cache each of this account's repositories may
        /// hold**: `8G`, `512M`, `unlimited`, or `default` to go back to the
        /// server's own (10 % of /data, capped at 8 GiB).
        ///
        /// A bigger cache means more objects are already inflated and reads
        /// are fast; a smaller one means more reads re-inflate their own delta
        /// chain out of the pack, ~0.5–2 ms an object. It is never a refusal
        /// and never an error: an account at its budget serves everything it
        /// has, correctly, more slowly. Nothing it bounds can be lost — every
        /// byte of it is re-derivable from the packs, which it does not bound.
        #[arg(long, value_name = "BYTES")]
        explode_budget: Option<String>,
    },
}

/// Repository verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum RepoCommand {
    /// Create a repository. Its namespace must already exist.
    Create {
        /// `<namespace>/<name>`, with or without a `.git` suffix.
        path: String,
        /// sha256 is the default for new repositories.
        #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
        hash: HashArg,
        /// The default branch, as a short name or a full ref.
        #[arg(long, value_name = "BRANCH")]
        default_branch: Option<String>,
        /// **Readable by anyone, with no key**: `git clone
        /// ssh://anonymous@host/ns/repo.git` works for a stranger. Writing
        /// still needs a grant. Private is the default.
        #[arg(long)]
        public: bool,
        /// A one-line description.
        #[arg(long, default_value = "")]
        description: String,
        /// **Take over a store already on disk at this name** instead of
        /// refusing it. Only for a matching hash (the store's `OBJECT_FORMAT`
        /// marker). Without this a create never adopts a directory it did not
        /// make: that is how a deleted repository came back (2026-08-30).
        #[arg(long)]
        adopt: bool,
    },
    /// List repositories.
    List {
        /// Only this namespace.
        #[arg(long, value_name = "NS")]
        namespace: Option<String>,
    },
    /// **Delete a repository. Without `--purge` this is a DATED TOMBSTONE, not
    /// a reclaim.**
    ///
    /// The catalog row goes, so the name stops working and nothing can be
    /// pushed to it — but the bytes stay, for `--retention-days` (30 by
    /// default), and `repo undelete <path>` puts it back exactly as it was.
    /// A background sweep reclaims them when the window closes.
    ///
    /// Why the window exists: `--purge` returns the bytes in about two seconds
    /// — 181 GiB to 33 MiB, measured — and a live mirror does not help,
    /// because the delete replicates to the standby too. Deleting the wrong
    /// repository is the mistake that will actually happen.
    ///
    /// **A tombstone still costs storage.** Its bytes are on the account until
    /// the window closes; `repo list-deleted` and `Admin.StorageInfo` report
    /// them, so the space a tombstone holds is never a surprise.
    Delete {
        /// `<namespace>/<name>`.
        path: String,
        /// **Reclaim the bytes NOW**, with no window and no undo.
        #[arg(long)]
        purge: bool,
        /// How long the bytes are kept. `0` means at the next sweep — an
        /// operator freeing space meant it.
        #[arg(long, value_name = "DAYS")]
        retention_days: Option<u32>,
    },
    /// **Put a soft-deleted repository back**, inside its retention window.
    ///
    /// It comes back as it was — the recorded hash kind above all. A
    /// repository recreated as sha256 over a sha1 store serves refs no client
    /// can read, which is why this is a verb and not `repo create --adopt`.
    Undelete {
        /// `<namespace>/<name>`.
        path: String,
    },
    /// **End a retention window early**: reclaim a soft-deleted repository's
    /// bytes now. There is no undo after it.
    Purge {
        /// `<namespace>/<name>`.
        path: String,
    },
    /// What is deleted and not yet purged, when the window closes, and the
    /// bytes each is still costing.
    ListDeleted {},
    /// **Store directories with no catalog row and no soft-delete tombstone —
    /// bytes nothing else on this box can see.**
    ///
    /// `repo list` reads the catalog and an orphan is not in it. `repo
    /// list-deleted`, `repo undelete`, `repo purge` and the retention sweep
    /// all key off a tombstone and an orphan has none. So a directory with
    /// neither was reachable by nothing at all — while `repo create` refused
    /// its name *because the directory was there*.
    ///
    /// MEASURED on the appliance 2026-08-30: ~25-39 GB standing at
    /// `alice/linux`, the wreckage of a failed kernel mirror, with the name
    /// permanently unusable and no verb able to touch the bytes.
    ///
    /// With `repo list` and `repo list-deleted` this closes the set: every
    /// `<name>.git` directory is in exactly one of the three.
    ListOrphans {
        /// Only this namespace.
        #[arg(long, value_name = "NS")]
        namespace: Option<String>,
    },
    /// **Reclaim an orphan's bytes.** There is no undo.
    ///
    /// Refuses anything that is not an orphan by name: a catalogued repository
    /// is `repo delete --purge`'s and a soft-deleted one is `repo undelete`'s
    /// or `repo purge`'s. To KEEP an orphan's objects instead, run `repo
    /// create <path> --adopt --hash <the OBJECT_FORMAT that list-orphans
    /// reports>`.
    Reclaim {
        /// `<namespace>/<name>`.
        path: String,
    },
    /// **One repository's stored bytes, out to ONE FILE.**
    ///
    /// The file is self-describing and copyable anywhere — another appliance,
    /// object storage, a laptop. It carries the verbatim pack bytes, the
    /// durable extent journal and the ref log; **no index travels**, because an
    /// index is a derivation and the receiver builds its own.
    ///
    /// `--from-generation` is a WATERMARK, not a diff: the pack is append-only,
    /// so everything above a byte offset is new and nothing below it changed.
    /// Pass a previous run's `to_generation` for an incremental archive.
    ///
    /// Safe while the server is running: the cut is taken at one length, and
    /// every byte below it is already fsynced.
    ArchiveOut {
        /// `<namespace>/<name>`.
        path: String,
        /// Where to write the archive.
        #[arg(long, value_name = "FILE")]
        to: std::path::PathBuf,
        /// Start above this `objects.pack` offset. 0 = a full archive.
        #[arg(long, default_value_t = 0, value_name = "N")]
        from_generation: u64,
    },
    /// **Put an archive back**, into a repository this server does not have.
    ///
    /// Restores the bytes and then puts the catalog row back (adopting the
    /// store it just wrote), so a restored repository is servable without a
    /// second command. An incremental archive is applied on top of its base and
    /// refuses by name if the base is not exactly what it was cut against.
    ArchiveIn {
        /// The archive file.
        file: std::path::PathBuf,
        /// `<namespace>/<name>` to restore it as. Its namespace must exist.
        #[arg(long = "as", value_name = "NS/NAME")]
        as_path: String,
        /// Restore the bytes only; do not touch the catalog.
        #[arg(long)]
        no_catalog: bool,
    },
}

/// `gunnar forge …` — issues, reviews and merges, as signed events (roadmap
/// phase 1).
///
/// Every verb that changes anything **signs** with the actor's own key, and the
/// event goes into an append-only log the same transparency machinery covers.
/// An approval is therefore evidence anybody can check later, without trusting
/// the server that stored it — which a row in a table saying `approved_by` is
/// not.
///
/// **This is the review half only.** The pull request itself — its number, its
/// refs, its state — is `PullRequests`' record under the control store's CAS,
/// and there is no CLI for it yet: open and merge one over gRPC.
///
/// The same events are reachable over gRPC as `Forge.*` — `Append` takes an
/// event the CALLER signed, verifies it against the identity key table and appends it,
/// because a server that could sign an approval could manufacture one. What is
/// deliberately not here yet is the console views, which live in the separate
/// `gunnar-UI` repository.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum ForgeCommand {
    /// Comment on a pull request.
    Comment {
        #[arg(long, value_name = "REPO")]
        repo: String,
        /// The pull request number, as `PullRequests.Create` assigned it.
        #[arg(long, value_name = "N")]
        pull: u64,
        #[arg(long, value_name = "TEXT")]
        body: String,
        /// The identity-table principal the event is attributed to.
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        /// The private key to sign with.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// **Approve a specific commit.**
    ///
    /// The commit is part of what is signed, so a force-push after the review
    /// leaves an approval that visibly names the commit that was reviewed — and
    /// not the one that is there now. `PullRequests.Merge` refuses on exactly
    /// that, so this is a gate and not a display.
    Approve {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "N")]
        pull: u64,
        #[arg(long, value_name = "OID")]
        commit: String,
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// **Withdraw an approval**, naming the commit it covered and why.
    ///
    /// Appended, never deleted: the log is append-only, so what the fold shows
    /// is that this actor no longer approves — and the withdrawal itself stays
    /// readable, which is the fact an auditor wants.
    Reject {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "N")]
        pull: u64,
        #[arg(long, value_name = "OID")]
        commit: String,
        #[arg(long, value_name = "TEXT")]
        reason: String,
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// Show a pull request's review: its comments, and who approves what.
    ///
    /// The review only. The pull request itself — its title, its refs, whether
    /// it is open — belongs to `PullRequests`, and a second copy printed here
    /// would be a second answer that can disagree.
    Show {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "N")]
        pull: u64,
    },
    /// **Issues**: open, comment, close, list and show — as signed events.
    ///
    /// An issue is not a pull request and has no record behind it: it IS the
    /// fold of its own signed events, so opening one and closing one are events
    /// here (a review has neither — those are the pull request record's). A
    /// signed opening is the property GitHub, GitLab and Gitea have no
    /// equivalent for: who filed it is the author's own key.
    #[command(subcommand)]
    Issue(IssueCommand),
}

/// `gunnar forge issue …` — issues as signed events.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum IssueCommand {
    /// **Open an issue.** Signs the title and body with the author's own key
    /// and appends it; the number is assigned by the fold and printed back.
    Open {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "TEXT")]
        title: String,
        #[arg(long, value_name = "TEXT", default_value = "")]
        body: String,
        /// The identity-table principal the issue is attributed to.
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        /// The private key to sign with.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// Comment on an issue.
    Comment {
        #[arg(long, value_name = "REPO")]
        repo: String,
        /// The issue number, as `issue open` (or the fold) assigned it.
        #[arg(long, value_name = "N")]
        number: u64,
        #[arg(long, value_name = "TEXT")]
        body: String,
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// **Close an issue**, naming why. Appended, never deleted — the close
    /// stays readable.
    Close {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "N")]
        number: u64,
        #[arg(long, value_name = "TEXT", default_value = "")]
        reason: String,
        #[arg(long, value_name = "PRINCIPAL")]
        actor: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: std::path::PathBuf,
    },
    /// List every issue in a repository, folded.
    List {
        #[arg(long, value_name = "REPO")]
        repo: String,
    },
    /// Show one issue: its state and its whole thread.
    Show {
        #[arg(long, value_name = "REPO")]
        repo: String,
        #[arg(long, value_name = "N")]
        number: u64,
    },
}

/// `gunnar verify` — the chain, walked backwards (roadmap phase 2).
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct VerifyArgs {
    /// `<namespace>/<repo>`.
    #[arg(value_name = "REPO")]
    pub repo: String,
    /// The revision to start from: a branch, a tag, `HEAD`, or a hex oid.
    #[arg(long, default_value = "HEAD", value_name = "REV")]
    pub at: String,
    /// `<path>:<line>`, one-based — the way an editor and `git blame` count.
    ///
    /// Without it the chain starts at the revision's own commit and link 1 is
    /// reported as unchecked rather than as passed.
    #[arg(long, value_name = "PATH:LINE")]
    pub line: Option<String>,
}

/// `gunnar blame <ns>/<repo> <path> [--rev REV]`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct BlameArgs {
    /// `<namespace>/<repo>`.
    #[arg(value_name = "REPO")]
    pub repo: String,
    /// The file to blame, as a path within the repository's tree.
    #[arg(value_name = "PATH")]
    pub path: String,
    /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
    #[arg(long, default_value = "HEAD", value_name = "REV")]
    pub rev: String,
}

/// `gunnar search <ns>/<repo> <pattern> [--rev REV] [--max-*]`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct SearchArgs {
    /// `<namespace>/<repo>`.
    #[arg(value_name = "REPO")]
    pub repo: String,
    /// The literal string to look for. Not a regular expression — see the verb's
    /// help for why the appliance does not link one yet.
    #[arg(value_name = "PATTERN")]
    pub pattern: String,
    /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only this
    /// one revision is searched, not the history behind it.
    #[arg(long, default_value = "HEAD", value_name = "REV")]
    pub rev: String,
    /// Skip files larger than this many bytes (they are never read).
    #[arg(long, default_value_t = 1 << 20, value_name = "BYTES")]
    pub max_file_bytes: u64,
    /// Stop after this many matches.
    #[arg(long, default_value_t = 1000, value_name = "N")]
    pub max_matches: usize,
}

/// The transparency log's verbs, over the push attestations.
///
/// The log itself is `attestations.jsonl` and is written by every push. These
/// verbs do not write records; they sign, check and prove over the records that
/// are already there — which is why none of them can change what the log says.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum AttestCommand {
    /// **Sign what the log holds right now**, and append the head.
    ///
    /// Issue one whenever a claim about the log needs to be checkable later —
    /// after a release, on a timer, before an audit. Each head pins every
    /// record beneath it, so a rewrite of an old line is caught by every head
    /// issued since, including by somebody who never saw the original line.
    Head,
    /// **Check every signed head against the records on disk.**
    ///
    /// The one verb that can say the log was tampered with. It keeps three
    /// failures apart on purpose: a head that does not verify (somebody wrote a
    /// head they could not sign, or the key rotated), a head that verifies over
    /// a root the records no longer produce (the records were changed), and a
    /// head covering more records than exist (lines were removed).
    Audit,
    /// The public key a verifier pins to check this server's tree heads.
    ///
    /// An OpenSSH public-key line, so `ssh-keygen -Y verify` can check a head
    /// with no gunnar involved.
    Key,
    /// **The inclusion proof for one push**, by the pack it landed.
    ///
    /// What a client checks offline: the record's position, the tree size and
    /// the sibling hashes. With a signed head it proves the push is in the log
    /// without trusting the server that served the proof.
    Prove {
        /// The `pack_hash` the attestation carries.
        #[arg(value_name = "PACK-HASH")]
        pack_hash: String,
    },
}

/// `gunnar issuer …` — the server's certificate authority.
///
/// **Exactly one issuer.** Not a list, not a default among several. A second
/// issuer is a second answer to *who may mint a user*, and the failure mode of
/// two is that nobody can say which one signed a given client. `create` on a
/// server that has one is refused; replacing it invalidates every certificate
/// in the field and is therefore a different verb.
///
/// The trusted-CA **list** is a separate thing and stays a list: a rotation
/// needs the old CA and the new one trusted at the same instant, or every
/// client is locked out at the swap. `trust`/`untrust` are that list.
///
/// **The certificate issuer.** Every verb but `show` writes, and writing needs
/// the storage half; `show` is a read and is here in either build.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Subcommand)]
pub enum IssuerCommand {
    /// Mint this server's issuer. **Refused if it already has one.**
    #[cfg(feature = "server")]
    Create {
        /// The subject common name the CA certificate carries.
        #[arg(long, default_value = "gunnar issuer")]
        subject: String,
        /// How long it is good for.
        #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_ISSUER_DAYS)]
        days: u32,
    },
    /// Adopt an issuer that already exists. **Refused if this server has one.**
    ///
    /// Both halves are checked against each other before anything is stored: a
    /// certificate that is not this key's is refused, rather than accepted and
    /// discovered on the first `principal add`.
    #[cfg(feature = "server")]
    Import {
        /// The CA certificate, PEM.
        #[arg(long, value_name = "PATH")]
        cert: std::path::PathBuf,
        /// Its private key, PEM. It goes straight into the sealed secret store
        /// and never into the control database.
        #[arg(long, value_name = "PATH")]
        key: std::path::PathBuf,
    },
    /// What is live: the issuer, and every CA this server trusts.
    ///
    /// **One verb, two places to stand.** With no `--at` it reads the data
    /// root under your feet, as it always has. With `--at <addr>` it asks a
    /// RUNNING server the same question over the control plane
    /// (`Admin.IssuerInfo`, server-wide `Read`) and prints the same lines.
    ///
    /// There is no second verb for the remote read, and that is a decision.
    /// `gunnar namespace` and `gunnar control namespace` are split because
    /// they are WRITES with different preconditions — the offline one needs
    /// the server stopped. This is a read of one document with no
    /// precondition at all: the answer is identical from either side, out of
    /// the same `pki_readonly` seam, and two spellings of one question would
    /// be two renderings to keep in step.
    ///
    /// # Why the wire half exists
    ///
    /// The appliance has no shell, so `--data-root` is unreachable on the box
    /// where it matters. The issuer certificate was otherwise handed out by
    /// exactly one RPC, `Bootstrap.Enroll`, whose window shuts for good on the
    /// first enrol — so a RESTORED appliance could never again tell anyone
    /// which CA it mints with, which is precisely when "is this still the CA
    /// my clients pinned?" needs an answer.
    Show {
        /// Print the issuer certificate itself, so it can be piped to a client.
        #[arg(long)]
        pem: bool,
        /// Ask a running server instead of this host's data root.
        ///
        /// No default and no environment variable on purpose: `issuer show`
        /// must not quietly start answering about a different machine because
        /// `GUNNAR_CONTROL_ADDR` was exported in the shell.
        #[arg(long, value_name = "ADDR", long_help = CONTROL_ADDR_HELP)]
        at: Option<String>,
        /// The private key to sign the call with. Only with `--at`.
        #[arg(long, short = 'i', value_name = "PATH", requires = "at")]
        identity: Option<PathBuf>,
    },
    /// Trust a CA this server does not mint with — the other half of a rotation.
    #[cfg(feature = "server")]
    Trust {
        /// A label. Never part of the credential.
        #[arg(long, default_value = "")]
        label: String,
        /// The CA certificate, PEM.
        #[arg(long, value_name = "PATH")]
        cert: std::path::PathBuf,
    },
    /// Stop trusting a CA. Refuses to remove the live issuer's own entry.
    #[cfg(feature = "server")]
    Untrust {
        /// The `SHA256:` fingerprint `issuer show` prints.
        fingerprint: String,
    },
    /// **Can this server be moved to `serve --grpc-tls-self`, and who does it
    /// cost?**
    ///
    /// Mutual TLS on the control plane refuses every peer that cannot present
    /// a certificate this server's issuer signed. That is the point of it, and
    /// it is also a cliff: a principal that holds only an SSH key can
    /// authenticate today and cannot complete a handshake afterwards. This
    /// prints, per principal, which side of the cliff they are on, and ends
    /// with the verdict `gunnar serve` itself will reach — it is the same
    /// function, asked in advance, so the two cannot disagree.
    ///
    /// Offline and local: it reads this host's identity table, because
    /// `--grpc-tls-self` is a serve flag and whoever flips it is already here.
    /// It changes nothing.
    #[cfg(feature = "server")]
    Readiness,
}

/// Principal verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum PrincipalCommand {
    /// Add a principal, and optionally issue its certificate.
    Add {
        /// A stable identifier. Grants name it, and it is the `CN` the
        /// certificate carries.
        id: String,
        /// A display name. Defaults to the id.
        #[arg(long)]
        name: Option<String>,
        /// What sort of actor this is.
        #[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
        kind: PrincipalKindArg,
        /// **Issue from a certificate signing request. This is the strong
        /// form.**
        ///
        /// The private key is generated on the machine that will use it and
        /// never leaves it, so this server is structurally incapable of
        /// impersonating the principal. Whatever subject the request claims is
        /// discarded: the certificate is issued under `id`.
        #[arg(long, value_name = "PATH", conflicts_with = "server_generated_key")]
        csr: Option<std::path::PathBuf>,
        /// **Generate the private key here and hand it back.** The convenient
        /// form, and the weaker one.
        ///
        /// The key existed on this server and travelled over the wire, so the
        /// server could have kept a copy and nothing in the protocol proves it
        /// did not — *"only alice could have signed this"* stops being true. It
        /// is spelled out rather than being what you get by not thinking about
        /// it, because a machine account being bootstrapped genuinely needs it.
        #[arg(long)]
        server_generated_key: bool,
        /// Where the issued `<id>_cert.pem` (and, for
        /// `--server-generated-key`, `<id>_key.pem`) are written.
        #[arg(long, value_name = "DIR", default_value = ".")]
        out: std::path::PathBuf,
        /// How long the issued certificate is good for.
        #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_LEAF_DAYS)]
        days: u32,
    },
    /// List principals.
    List,
    /// Remove a principal, with its keys and grants.
    Remove {
        /// The principal id.
        id: String,
    },
}

/// Key verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum TokenCommand {
    /// Mint an access token for a principal, and **print it once**.
    ///
    /// The secret is not stored: only its sha-256 is. Nothing in gunnar can
    /// print it again, which is why this is the only place it appears.
    Add {
        /// The principal the token authenticates as. It gets exactly that
        /// principal's grants and nothing else.
        principal: String,
        /// A label — which laptop, which CI job. Never part of the credential.
        #[arg(long, default_value = "")]
        label: String,
        /// Stop accepting it after this many days.
        #[arg(long, value_name = "DAYS")]
        expires_in: Option<u64>,
    },
    /// List tokens by their hash, which is what `revoke` takes.
    List {
        /// Only this principal's.
        #[arg(long)]
        principal: Option<String>,
    },
    /// Revoke a token by the hash `list` prints.
    Revoke {
        /// The token hash.
        hash: String,
    },
}

/// WireGuard verbs.
///
/// One verb, and it exists because provisioning an OS-less fleet needs a key
/// and `wg genkey` is a SHELL — a thing this product does not have, on the
/// appliance or in its build. The curve is already linked into this binary for
/// the tunnel itself, so minting a key costs nothing that was not already
/// there.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum WgCommand {
    /// Mint a fresh x25519 keypair — what `wg genkey | wg pubkey` prints,
    /// without a shell.
    Keygen {
        /// Write the PRIVATE key here (one base64 line, mode 0600) instead of
        /// printing it, and print only the public half.
        ///
        /// This is the `wg0.key` an appliance is seeded with. An existing file
        /// is REFUSED rather than overwritten: a regenerated identity is a
        /// silent fleet-wide outage — every hub still allowlists the old public
        /// key, and a peer whose key is unknown gets silence, not an error.
        #[arg(long, value_name = "PATH")]
        private: Option<std::path::PathBuf>,
    },
}

/// Public-key verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum KeyCommand {
    /// Register an OpenSSH public key line to a principal.
    Add {
        /// The principal id.
        principal: String,
        /// An OpenSSH public key line, or `@<path>` to read one from a file.
        ///
        /// Omit when registering a certificate with `--cert`.
        #[arg(required_unless_present = "cert")]
        key: Option<String>,
        /// Register an X.509 certificate instead, from a PEM or DER file.
        ///
        /// The mTLS credential. It lands in the same table as an SSH key and
        /// under the same principal, because a fingerprint is all gunnar knows
        /// about a credential — only how it is presented differs.
        #[arg(long, conflicts_with = "key", value_name = "PATH")]
        cert: Option<std::path::PathBuf>,
    },
    /// List registered keys.
    List {
        /// Only this principal's keys.
        #[arg(long)]
        principal: Option<String>,
    },
    /// Revoke a key by its `SHA256:` fingerprint.
    Revoke {
        /// The fingerprint, exactly as `ssh-keygen -lf` prints it.
        fingerprint: String,
    },
}

/// `gunnar grant`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
pub struct GrantArgs {
    /// The principal id.
    pub principal: String,
    /// How much access.
    #[arg(value_enum)]
    pub level: LevelArg,
    /// Scope the grant to the whole server (the bootstrap admin's scope).
    #[arg(long)]
    pub server: bool,
    /// Scope the grant to one namespace, matched EXACTLY: `team` does not reach
    /// `team/sub`.
    #[arg(long, value_name = "NS")]
    pub namespace: Option<String>,
    /// Scope the grant to one repository, `<namespace>/<name>`.
    #[arg(long, value_name = "NS/NAME")]
    pub repo: Option<String>,
}

/// `gunnar revoke`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
pub struct RevokeArgs {
    /// The principal id.
    pub principal: String,
    /// The server-wide grant.
    #[arg(long)]
    pub server: bool,
    /// A namespace grant.
    #[arg(long, value_name = "NS")]
    pub namespace: Option<String>,
    /// A repository grant.
    #[arg(long, value_name = "NS/NAME")]
    pub repo: Option<String>,
}

/// `gunnar reflog`.
///
/// Reads the archive `gunnar-store`'s compaction has always written and until
/// now nothing read. It opens **no store**, so it works against a repository a
/// running server holds the exclusive lock on — which is the only kind of
/// repository anyone wants the history of.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct ReflogArgs {
    /// The repository, `<namespace>/<name>`. Omit for every one.
    pub path: Option<String>,

    /// Write the sealed history to this path instead of listing it.
    ///
    /// One Arrow IPC stream, one batch, the push boundary carried as a
    /// `push_seq` column rather than as a frame boundary — measured at 6.68x
    /// smaller per push than the live log it seals. With more than one
    /// repository this is treated as a **directory** and one artifact is
    /// written per repository, because a single file would silently keep only
    /// the last.
    #[arg(long, value_name = "PATH")]
    pub seal: Option<std::path::PathBuf>,

    /// Print at most this many of the most recent transactions per repository.
    #[arg(long, default_value_t = 20, value_name = "N")]
    pub limit: usize,

    /// Print every transaction, ignoring `--limit`.
    #[arg(long, conflicts_with = "limit")]
    pub all: bool,
}

/// `gunnar gc`.
///
/// `Default` exists so that a caller — the tests, and any future programmatic
/// `gc` — can name the one field it cares about and inherit the verb's real
/// defaults for the rest. Spelling every flag out at each call site is how a
/// test ends up pinned to `Preserve` because that was the easiest literal to
/// write, and then guards nothing.
///
/// **It is hand-written, and it has to be.** A derive gives `0` for every
/// integer, and `--window 0`/`--depth 0` is a delta search that can never find
/// a base: every `GcArgs { .. , ..Default::default() }` in the tests would
/// silently stop deltifying and every one of them would still pass, because a
/// pack of whole objects is a valid pack. `gc_default_matches_the_command_line`
/// asserts the two agree, field by field.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct GcArgs {
    /// The repository to maintain, `<namespace>/<name>`. Omit for every one.
    pub path: Option<String>,

    /// Also drop objects nothing reaches.
    ///
    /// Off unless asked, because reachability is computed from the refs as they
    /// are at this instant and a push landing mid-run is a race the grace
    /// window below exists to cover.
    #[arg(long)]
    pub prune: bool,

    /// How long an unreachable object is kept before `--prune` may drop it.
    #[arg(long, value_name = "DURATION", default_value = "2w", value_parser = parse_prune_grace)]
    pub prune_grace: std::time::Duration,
}

/// `now`, `<N>`, or `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
///
/// Hand-written rather than pulling in a date parser: the dependency budget is
/// a guarded number (`xtask guard`) and this is eleven lines. It deliberately
/// does **not** accept git's full `approxidate` grammar (`2.weeks.ago`), because
/// half-supporting a grammar is how `--prune=yesterday` ends up silently
/// meaning zero.
#[cfg(feature = "server")]
fn parse_prune_grace(raw: &str) -> std::result::Result<std::time::Duration, String> {
    parse_duration_words(raw, "grace period", "2w")
}

/// `--max-silence` on `control twin replay`: the same grammar as [`parse_prune_grace`].
#[cfg(feature = "control")]
fn parse_max_silence(raw: &str) -> std::result::Result<std::time::Duration, String> {
    parse_duration_words(raw, "silence", "24h")
}

/// The grammar of [`parse_prune_grace`], naming `what` and an `example` in its refusals.
#[cfg(any(feature = "server", feature = "control"))]
fn parse_duration_words(
    raw: &str,
    what: &str,
    example: &str,
) -> std::result::Result<std::time::Duration, String> {
    let raw = raw.trim();
    if raw.eq_ignore_ascii_case("now") {
        return Ok(std::time::Duration::ZERO);
    }
    let (digits, unit) = match raw.as_bytes().last() {
        Some(b) if b.is_ascii_digit() => (raw, 1u64),
        Some(b's') => (&raw[..raw.len() - 1], 1),
        Some(b'm') => (&raw[..raw.len() - 1], 60),
        Some(b'h') => (&raw[..raw.len() - 1], 60 * 60),
        Some(b'd') => (&raw[..raw.len() - 1], 24 * 60 * 60),
        Some(b'w') => (&raw[..raw.len() - 1], 7 * 24 * 60 * 60),
        _ => {
            return Err(format!(
                "{raw:?} is not a {what}; write `now`, a number of seconds, or a number \
                 with a s/m/h/d/w suffix such as `{example}`"
            ))
        }
    };
    let n: u64 = digits.parse().map_err(|_| {
        format!(
            "{raw:?} is not a {what}: {digits:?} is not a number. Write `now`, a number \
             of seconds, or a number with a s/m/h/d/w suffix such as `{example}`"
        )
    })?;
    n.checked_mul(unit)
        .map(std::time::Duration::from_secs)
        .ok_or_else(|| format!("{raw:?} is not a {what}: it does not fit in a duration"))
}

#[cfg(feature = "server")]
impl Default for GcArgs {
    fn default() -> Self {
        GcArgs {
            path: None,
            prune: false,
            // The same value as `default_value = "2w"` above;
            // `gc_default_matches_the_command_line` fails if they drift.
            prune_grace: std::time::Duration::from_secs(14 * 24 * 60 * 60),
        }
    }
}

#[cfg(feature = "server")]
impl GcArgs {}

/// The hash kinds gunnar knows.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HashArg {
    /// Git's original hash.
    Sha1,
    /// The default for new repositories.
    Sha256,
}

#[cfg(feature = "server")]
impl From<HashArg> for gunnar_core::HashKind {
    fn from(arg: HashArg) -> Self {
        match arg {
            HashArg::Sha1 => gunnar_core::HashKind::Sha1,
            HashArg::Sha256 => gunnar_core::HashKind::Sha256,
        }
    }
}

/// Grant levels, ordered `read < write < admin`.
///
/// Shared by the offline `gunnar grant` and the online `gunnar control grant`,
/// so `--level admin` means one thing whichever side of the wire it is spelled
/// on (one implementation).
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LevelArg {
    /// `git-upload-pack`: clone and fetch.
    Read,
    /// `git-receive-pack`: push. Implies read.
    Write,
    /// Administer. Implies write.
    Admin,
}

#[cfg(feature = "control")]
impl LevelArg {
    /// The proto enum this level is on the wire.
    pub fn to_proto(self) -> gunnar_proto::GrantLevel {
        match self {
            LevelArg::Read => gunnar_proto::GrantLevel::Read,
            LevelArg::Write => gunnar_proto::GrantLevel::Write,
            LevelArg::Admin => gunnar_proto::GrantLevel::Admin,
        }
    }
}

#[cfg(feature = "server")]
impl From<LevelArg> for gunnar_core::Level {
    fn from(arg: LevelArg) -> Self {
        match arg {
            LevelArg::Read => gunnar_core::Level::Read,
            LevelArg::Write => gunnar_core::Level::Write,
            LevelArg::Admin => gunnar_core::Level::Admin,
        }
    }
}

/// The three kinds of principal in the identity table.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PrincipalKindArg {
    /// A human.
    User,
    /// CI, a mirror, a robot with its own identity.
    Machine,
    /// A key scoped to one repository.
    Deploy,
    /// A group of principals (punkt 9): holds no key, is granted access like
    /// any principal, and its members inherit those grants. Prefer
    /// `gunnar control group add`, which is this with a name for it.
    Group,
}

#[cfg(feature = "server")]
impl From<PrincipalKindArg> for gunnar_core::PrincipalKind {
    fn from(arg: PrincipalKindArg) -> Self {
        match arg {
            PrincipalKindArg::User => gunnar_core::PrincipalKind::User,
            PrincipalKindArg::Machine => gunnar_core::PrincipalKind::Machine,
            PrincipalKindArg::Deploy => gunnar_core::PrincipalKind::Deploy,
            PrincipalKindArg::Group => gunnar_core::PrincipalKind::Group,
        }
    }
}

// ───────────────────────────────────────────────────────────────────────────
// the control plane, over the wire
// ───────────────────────────────────────────────────────────────────────────

/// Where a `gunnar control` verb sends its call.
///
/// Its own arg rather than a global one, because it must appear in the
/// `--help` of each verb that uses it: a first-time user reading
/// `gunnar control bootstrap --help` has to see where it is about to send an
/// unauthenticated credential-granting call.
///
/// Declared in the SERVER build too, because `gunnar issuer show --at` is:
/// that binary must still parse the flag in order to refuse it BY NAME —
/// telling an operator "this binary carries no control client" rather than
/// "unexpected argument" — which is the same asymmetry `--monetize-pubkey`
/// has in the open build. It is a `&str`; it pulls in nothing.
#[cfg(any(feature = "control", feature = "server"))]
pub const CONTROL_ADDR_HELP: &str =
    "Where the control plane is. gunnar binds it to LOOPBACK by default, so the default here is \
     this host's server. To reach another host, tunnel to its loopback \
     (`ssh -L 50051:127.0.0.1:50051 <host>`) rather than binding it publicly. An estate is \
     reached through its site, `--at https://<site>`: its front carries the call, signed with \
     your key, to the main (GUNNAR_SITE_CA names the site's own CA, if a public one did not \
     issue its certificate).";

/// `gunnar control admit …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum AdmitCommand {
    /// How each kind of transfer for one namespace would be admitted
    /// (`Usage.AdmitPlan`).
    Show {
        #[arg(value_name = "NAMESPACE")]
        namespace: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlCommand {
    /// Ask a server whether it has been enrolled. **Needs no credential.**
    ///
    /// It is the one question a server that nobody holds a key for can answer,
    /// and an unenrolled server is exactly that.
    Status {
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Step 1: claim a fresh server.** Creates the first user, who is
    /// gunnar-admin. Needs NO credential — and works exactly once, ever.
    ///
    /// The window is open only while no identity has ever been enrolled. It
    /// closes permanently on the first success, and deleting every user does
    /// not reopen it.
    Bootstrap {
        /// Your name. It is also your ACCOUNT: you are always admin of
        /// `<host>/<name>/<repo>`, and that is not a grant anybody can revoke.
        name: String,
        /// An OpenSSH public key line, or `@<path>`. Defaults to the public
        /// half of the key `-i` names, so neither flag is usually needed.
        #[arg(long, value_name = "KEY")]
        key: Option<String>,
        /// The private key whose public half to enrol, and to authenticate
        /// with afterwards.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        /// Where the client certificate the server issues (`<name>.cert.pem`,
        /// `<name>.key.pem`, `gunnar-issuer.pem`) is written. Defaults to a
        /// directory of your own, `~/.config/gunnar/<server>/`, created 0700 —
        /// never the current directory, which is a git checkout more often
        /// than not, and a private key in a checkout is one `git add` from a
        /// remote.
        #[arg(long, value_name = "DIR")]
        out: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Step 3: log in** with your ssh key, and see what you are.
    ///
    /// Writes nothing. gunnar keeps no session on either side: every call signs
    /// a fresh single-use credential, so there is no state for `bootstrap` to
    /// have left behind and none for this to create.
    Login {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Step 4: users.** Authenticated, and creating one needs gunnar-admin.
    #[command(subcommand)]
    User(ControlUserCommand),

    /// **Groups: grant access to a team, not one person at a time** (punkt 9).
    ///
    /// A group is a principal that holds no key. Grant it access with the
    /// ordinary `gunnar control grant <group> …`, add people to it, and they
    /// inherit the grant; remove them and they lose it. Managing a group needs
    /// gunnar-admin, exactly like a grant.
    #[command(subcommand)]
    Group(ControlGroupCommand),

    /// Repositories, against a running server.
    ///
    /// You may always create one in your OWN account with no grant at all;
    /// somebody else's needs a grant on their namespace.
    #[command(subcommand)]
    Repo(ControlRepoCommand),

    /// **Is the record of what happened on this box still complete?**
    /// (`Admin.AuditTrail`, server-wide `Read`.)
    ///
    /// The control plane has written a tamper-evident hash chain of its
    /// decisions since GUN-044, and until 2026-09-21 there was no way to read
    /// it except as root on the box — which an appliance does not have. This
    /// prints the chain's shape: how many entries, the tip hash, whether the
    /// chain still verifies, and whether it is replicated to the twin.
    ///
    /// It prints no entry. Who did what is a read of the file; this verb
    /// answers completeness.
    ///
    /// # After a restore
    ///
    /// A restored box keeps appending, so its TIP has moved by the time you
    /// ask. Give `--at-seq <N>` the last sequence the old primary reported and
    /// compare the `hash at seq` line: because every entry's hash commits to
    /// its `prev`, one matching hash proves the whole prefix came back
    /// unaltered.
    Audit {
        /// Ask for the hash this chain carried at this sequence number — the
        /// prefix proof a restore is checked with.
        #[arg(long, value_name = "N")]
        at_seq: Option<u64>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// Namespaces, against a running server.
    ///
    /// The offline `gunnar namespace` writes the control repository directly
    /// and therefore needs the server **stopped**. These do not.
    #[command(subcommand)]
    Namespace(ControlNamespaceCommand),

    /// **Accounts: lock a user out, and empty their account for good.**
    ///
    /// The per-USER half of what `repo` does per repository. `repo delete
    /// --purge` reaches one repository at a time; an account can hold hundreds
    /// of them plus soft-delete tombstones and orphan stores that no other
    /// listing shows, and there was no verb whose unit was a *user*.
    ///
    /// Every one of these needs **gunnar-admin** — Admin at server scope, not
    /// on the namespace. A namespace check would be self-granting: you are
    /// unconditionally admin of your own account, so it would let any user
    /// hard-purge themselves with one call and no undo.
    #[command(subcommand)]
    Account(ControlAccountCommand),

    /// **Clans: ordinary accounts grouped, each giving part of its own room.**
    ///
    /// A clan has a namespace of its own (private, like every account's) and
    /// an admin — its creator — who invites accounts and administers what is
    /// made in it: visibility, who else may reach it, deletion. Its members
    /// read, write and create private repositories there. Its room is the sum
    /// of what its members give out of their own; a contribution goes down
    /// only as far as what the clan stores, and leaving gives it back.
    /// Every verb is YOURS: gunnar decides from the clan who may.
    #[command(subcommand)]
    Clan(ControlClanCommand),

    /// **Pull mirroring: where this server's copy comes FROM.**
    ///
    /// The pull direction, and the exact mirror of `replication` below: that one
    /// says where a second copy of gunnar's repositories is pushed TO, this one
    /// says which other forge a repository here is pulled from. One service on
    /// the wire, because "where does this repository's history also live" is one
    /// question asked from two sides.
    ///
    /// `import` is the door: it lists a whole GitHub/Codeberg/GitLab/Forgejo
    /// account over that forge's REST API and mirrors what you choose, which is
    /// how a person moves in without a migration project.
    ///
    /// Not to be confused with `gunnar mirror` (no `control`), which keeps a
    /// copy in a data root on the machine you typed it on. This one talks to a
    /// server.
    #[command(subcommand)]
    Mirror(ControlMirrorCommand),

    /// **Replication: where a second copy of everything lives.**
    ///
    /// The appliance has no shell. `<data-root>/replication.json` is the truth
    /// and it survives a reboot, but on an OS-less image running as PID 1 there
    /// is no editor to write it with — so these are how a standby is named, and
    /// they are the only way.
    ///
    /// A target is configurable BEFORE any repository exists: replication is
    /// scoped to the server, a namespace or a repository, and nothing is
    /// looked up when you set one.
    #[command(subcommand)]
    Replication(ControlReplicationCommand),

    /// **The twin: the box that holds every transaction, and the restore
    /// from it.**
    ///
    /// A twin follows a primary's transaction log — every pack, ref edit,
    /// control-database write, secret and LFS object, in the order the
    /// primary was told — into a data root of its own, and never serves. On
    /// a gap it fetches the missing range first; when the primary no longer
    /// retains what it needs it stops and says `snapshot-needed`. `restore`
    /// replays a twin's log into a fresh data root.
    #[command(subcommand)]
    Twin(ControlTwinCommand),

    /// **The WireGuard tunnel address: handed over at bootstrap, never baked
    /// into the medium.** `enrol` allocates the next free address of the
    /// site's pool (keyed by the box's minted public key), hands it to the box
    /// over `Bootstrap.AssignTunnelAddress`, and registers the box's `[Peer]`
    /// in the hub's WireGuard config.
    #[command(subcommand)]
    Tunnel(ControlTunnelCommand),

    /// **Outbound events: send them to an external URL (webhooks).**
    ///
    /// A webhook is a subscriber to gunnar's event stream that POSTs each
    /// matching event to a URL — the way a CI/CD pipeline is triggered when a
    /// ref moves, without a process camped on `Events.Watch`. Every delivery is
    /// SIGNED with the server's key (an `sshsig`, no shared secret), so the
    /// receiver verifies it came from this gunnar with `ssh-keygen -Y verify`.
    ///
    /// Configured over the control plane and durable in
    /// `<data-root>/webhooks.json`, for the same reason replication is: the
    /// appliance has no shell.
    #[command(subcommand)]
    Webhook(ControlWebhookCommand),

    /// **The pre-receive gate: branch protection and ref-name policy.**
    ///
    /// A merge already needs a verified signed approval naming the exact
    /// commit. Nothing stopped a DIRECT push to the same branch, because the
    /// only surface that could was reachable from neither this CLI nor the
    /// console — while the server had been serving `Policy.*` out of
    /// `refs/gunnar/policy` the whole time. These are that surface.
    #[command(subcommand)]
    Policy(ControlPolicyCommand),

    /// Give a principal access, against a running server — or `grant list`
    /// to see who holds what.
    ///
    /// `gunnar control grant <principal> <level> --repo <ns>/<r>` gives;
    /// `gunnar control grant list` reads the whole table back. The one name
    /// this shadows is a principal literally called `list`, which `user add`
    /// will let you create and this verb can then not grant to.
    #[command(args_conflicts_with_subcommands = true)]
    Grant {
        #[command(subcommand)]
        command: Option<ControlGrantCommand>,
        /// The principal id.
        principal: Option<String>,
        /// How much access.
        #[arg(value_enum)]
        level: Option<LevelArg>,
        /// Scope it to the whole server. This IS gunnar-admin at `admin`.
        #[arg(long)]
        server: bool,
        /// Scope it to one namespace.
        #[arg(long, value_name = "NAME")]
        namespace: Option<String>,
        /// Scope it to one repository, `<namespace>/<repo>`.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Take a grant back**, against a running server.
    ///
    /// The mirror of `grant`, minus the level: a scope holds at most ONE
    /// grant per principal — granting on the same scope replaces — so the
    /// principal and the scope name the grant, and there is no level to
    /// choose. Revoking a grant that is not there is an ERROR, not a quiet
    /// success: "revoked" about a grant that never existed reads identically
    /// to "revoked" about the one you meant, and the difference is whether
    /// somebody still has access.
    ///
    /// A principal's OWN account is not a grant and cannot be revoked here;
    /// `user remove` takes the whole principal.
    Revoke {
        /// The principal id.
        principal: String,
        /// The server-wide grant. This is how gunnar-admin is taken back.
        #[arg(long)]
        server: bool,
        /// The grant on one namespace.
        #[arg(long, value_name = "NAME")]
        namespace: Option<String>,
        /// The grant on one repository, `<namespace>/<repo>`.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// The server's own health, authenticated: version, uptime and both
    /// listener binds.
    ///
    /// `status` is the unauthenticated question ("is this box enrolled, does
    /// it gate callers"); this is the authenticated one — what a headless
    /// appliance would tell you on a console you cannot see.
    Health {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **The public key this appliance signs actor tickets with**, as 64 hex
    /// characters — the string a monetize needs in `[products.<id>]
    /// appliance_key` before a tenant can renew their own subscription.
    ///
    /// `Billing.ApplianceIdentity` has served this since it was written, "so an
    /// operator can configure their monetize with it", and until 2026-09-19
    /// nothing but a test ever asked: there was no verb. MEASURED on gunnar.rs
    /// that day — every tenant's own renewal answered `501 unimplemented`,
    /// because the plugin had no key to check a ticket with and correctly
    /// refuses rather than trusting one it cannot verify.
    ///
    /// It is a PUBLIC key. Admin at server scope because it is operator
    /// configuration, not because the string is a secret — it VERIFIES tickets,
    /// it does not mint them.
    ///
    /// `metered` only, like the service it calls: `billing.proto` is not in
    /// codegen without the feature, and the community build "never mentions
    /// money at all" — an appliance that cannot sell a period carries no
    /// service that mints the permission to buy one, so a verb asking for one
    /// would be a promise the binary cannot keep.
    #[cfg(feature = "metered")]
    ApplianceIdentity {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **A namespace's plan, as this box ENFORCES it** — and the one door that
    /// hands the box a fact monetize signed.
    ///
    /// `Entitlement.Get/List/Set` had no verb: the only caller of `Set` was
    /// monetize's own poller, so a bench, a restore drill or an operator after
    /// a rescue could not put a fact on a box or ask what it was enforcing
    /// without writing a gRPC client. The reads answer out of the snapshot the
    /// front door reads, so a fact refused at boot — the wrong
    /// `--monetize-pubkey` after a restore — reads as Free here too.
    ///
    /// `metered` only, like `appliance-identity`: `entitlement.proto` is not in
    /// codegen without it.
    #[cfg(feature = "metered")]
    #[command(subcommand)]
    Entitlement(ControlEntitlementCommand),

    /// The server's counters: principals, keys, grants, repositories, the
    /// event bus, tombstones, replication and index work in flight.
    Stats {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **"If a tenant's cap rose to N bytes, could you serve it?"** — the
    /// question to ask BEFORE adding a disk.
    ///
    /// Not "how much is free". The answer is a verdict measured off the same
    /// `statvfs` the push door refuses under, AND — separately — whether this
    /// data set can ever grow at all. On the appliance it cannot: `/data` is an
    /// md set laid once at install, gunnar has no runtime growth path, and
    /// gunnar is PID 1 with no shell, so a disk attached to it would be added for nothing
    /// and stay invisible. Both halves are printed, on the yes arm as well as
    /// the no arm.
    Capacity {
        /// The cap being contemplated, in bytes: `--bytes 107374182400` asks
        /// "could you serve a tenant capped at 100 GiB?".
        #[arg(long, value_name = "BYTES")]
        bytes: u64,
        /// Whose cap is rising. Bytes it ALREADY holds are already spent out
        /// of free space, so only the difference needs new room. Without it
        /// the harder question is asked: could you serve `--bytes` that are
        /// not here yet.
        #[arg(long, value_name = "NAMESPACE")]
        namespace: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Grow the data set WITHOUT a restart** (`DATA-SET-GROWTH-FLOW.md`
    /// §3): drain, unmount, wait for every member volume to come back
    /// larger, grow in place, mount, reopen. `status` needs no credential.
    #[command(subcommand)]
    Grow(ControlGrowCommand),

    /// What is on disk: which backends are behind the two traits, hot and
    /// cold bytes, and one row per repository.
    Storage {
        /// Only this repository, `<namespace>/<repo>`. Without it, the
        /// server-wide totals and no per-repository rows.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **The estate's boxes, as this gunnar knows them** (`Admin.Topology`):
    /// this box first (primary or twin), then its twins and standbys with their
    /// live state, the consoles that call it and the monetize it registers
    /// with — one row per box, each with the sentence that says how gunnar
    /// knows. The same rows the console's Machine card draws.
    Topology {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **The meters, per namespace: what is held and what moved.**
    ///
    /// `pack_bytes` (the packs and their indexes — the stored bytes),
    /// `cache_bytes` (the exploded read cache, re-derivable), `lfs_bytes`,
    /// `tombstoned_bytes` (soft-deleted, still held, still counted), `repos`,
    /// and `objects` (the indexed objects of stores open right now — the RAM in
    /// use).
    ///
    /// Since the flow ledger it also prints what MOVED today — `in`, `out`,
    /// `pushes`, `fetches`, `refused` — and when the namespace was last seen.
    /// Those are counts of events and not readings of a disk; `gunnar control
    /// activity <namespace>` is the same numbers day by day.
    ///
    /// Open source and always on: a self-hoster reads exactly the numbers a
    /// service provider meters from. Needs `read` on the namespace, or a
    /// server-wide `read` for every namespace.
    Usage {
        /// One namespace. Without it, every namespace the catalog holds.
        #[arg(value_name = "NAMESPACE")]
        namespace: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **What MOVED through a namespace, day by day.**
    ///
    /// Every number `gunnar control usage` prints is a STOCK meter: it answers
    /// *how much is on the disk*, and it reads exactly the same for a namespace
    /// that was pushed to this morning and one that was last touched in March.
    /// This is the other kind — bytes in and out, pushes, fetches, refusals
    /// (with the reason), the keys that were seen, and when the namespace was
    /// last used. One row per UTC day that moved something; a day with no
    /// traffic has no row.
    ///
    /// Open source and always on, as `usage` is. Needs `read` on the namespace
    /// — a tenant's own traffic is a tenant's own business, so this takes the
    /// same grant `usage <namespace>` does and never a server-wide one.
    Activity {
        /// The namespace. Required: activity is per tenant, and there is no
        /// server-wide answer that would mean anything.
        #[arg(value_name = "NAMESPACE")]
        namespace: String,
        /// How many days back, ending today. The server keeps 400.
        #[arg(long, default_value_t = 30, value_name = "N")]
        days: u32,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Admission: what a namespace's next transfer would get, and why.**
    ///
    /// `gunnar control admit show <namespace>` prints the usage over the
    /// rolling window, the budget and what is left of it, and per kind of work
    /// (clone, fetch, push, request) the class the gate would give it, the
    /// learnt estimate and the gate's own sentence. Needs `read` on the
    /// namespace — a tenant may read their own budget.
    Admit {
        #[command(subcommand)]
        command: AdmitCommand,
    },

    /// **Who wrote each line of a file, against a RUNNING server.**
    ///
    /// The offline `gunnar blame` opens the data root directly and takes an
    /// exclusive lock, so it cannot run while a `gunnar serve` holds the same
    /// root — which on a sealed appliance with no shell means it cannot run at
    /// all. This asks the running server to walk its own open store and answer
    /// over the control plane, so a headless box can finally be blamed. Needs
    /// `read` on the repository.
    Blame {
        /// `<namespace>/<repo>`.
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// The file to blame, as a path within the repository's tree.
        #[arg(value_name = "PATH")]
        path: String,
        /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
        #[arg(long, default_value = "HEAD", value_name = "REV")]
        rev: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **A literal string across one revision's files, against a RUNNING
    /// server.** The up-server twin of `gunnar search`, reachable on a
    /// no-shell appliance for the same reason `control blame` is. Needs `read`
    /// on the repository.
    Search {
        /// `<namespace>/<repo>`.
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// The literal string to look for. Not a regular expression.
        #[arg(value_name = "PATTERN")]
        pattern: String,
        /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only
        /// this one revision, not the history behind it.
        #[arg(long, default_value = "HEAD", value_name = "REV")]
        rev: String,
        /// Skip files larger than this many bytes (they are never read). 0
        /// means the server default.
        #[arg(long, default_value_t = 0, value_name = "BYTES")]
        max_file_bytes: u64,
        /// Stop after this many matches. 0 means the server default.
        #[arg(long, default_value_t = 0, value_name = "N")]
        max_matches: u32,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Show a pull request's review, against a RUNNING server.**
    ///
    /// The comments and the approvals, with every approval re-checked against
    /// the identity key table right now — the same fold `gunnar forge show` prints,
    /// but reached over the control plane. The offline `gunnar forge show`
    /// opens the data root under an exclusive lock and so cannot run while a
    /// `gunnar serve` holds it, which on a sealed appliance with no shell means
    /// an operator has no other door to the review at all. Whether the pull
    /// request is open and what it merged at are `gunnar control pull`'s
    /// answers, deliberately not duplicated here. Needs `read` on the
    /// repository.
    Show {
        /// `<namespace>/<repo>`.
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// The pull request number, as `PullRequests.Create` assigned it.
        #[arg(value_name = "N")]
        pull: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Merge a reviewed pull request, against a RUNNING server.**
    ///
    /// Fast-forward only, and gated: the server refuses unless a verified
    /// approval names the exact commit the target ref is about to move to, so a
    /// force-push after the review does not inherit it. This is the merge an
    /// operator on a sealed appliance runs — there is no other door to
    /// `PullRequests.Merge` from a shell-less box — and the gate's rules (the
    /// approval names the commit, forbid-self-approval, required-approvals) are
    /// the server's, applied unchanged; this is only the verb that reaches
    /// them. Needs `write` on the repository.
    Merge {
        /// `<namespace>/<repo>`.
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// The pull request number to merge.
        #[arg(value_name = "N")]
        pull: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **The key gunnar signs its merge commits with** (`PullRequests.MergeSigningKey`).
    ///
    /// Prints the public half (one OpenSSH line), its fingerprint, and the line for
    /// git's allowed signers file, so `git verify-commit` (with
    /// `gpg.ssh.allowedSignersFile`) checks a merge commit gunnar wrote. The private
    /// half never leaves the box.
    MergeKey {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Who am I on this server?** (`Access.WhoAmI`.) The principal your key
    /// belongs to and the highest server-wide level it holds. Needs no grant.
    Whoami {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Pull requests, against a RUNNING server** (`PullRequests.*`): open,
    /// list, show and close one. Merging is `gunnar control merge`.
    #[command(subcommand)]
    Pr(ControlPrCommand),

    /// **Issues, against a RUNNING server**, signed with YOUR key and appended
    /// with `Forge.Append` — the online twin of the offline `gunnar forge issue`.
    #[command(subcommand)]
    Issue(ControlIssueCommand),

    /// **A pull request's review, against a RUNNING server**: read it, comment
    /// on it, approve a commit — the events signed with YOUR key.
    #[command(subcommand)]
    Review(ControlReviewCommand),

    /// **The live event feed** (`Events.Watch`).
    #[command(subcommand)]
    Events(ControlEventsCommand),

    /// **A first-login link onto an existing person** (`Access.EnrolmentLink`,
    /// IDENTITY-HOST): minted by the identity core, a server admin's.
    #[command(subcommand)]
    Invite(ControlInviteCommand),
}

/// `gunnar control invite …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlInviteCommand {
    /// Create one; prints its id and its SECRET — shown here once, never again.
    Create {
        /// The person whose passkey it enrols.
        #[arg(value_name = "PRINCIPAL")]
        principal: String,
        /// Days until it expires (at most 30).
        #[arg(long, value_name = "DAYS", default_value_t = 7)]
        expires_in_days: u32,
        /// Refuse a person who already holds a passkey (their FIRST one only).
        #[arg(long)]
        first_only: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control pr …` — pull requests over the control plane.
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlPrCommand {
    /// Open a pull request from `--source` into `--target`. Needs `write`.
    Create {
        /// `<namespace>/<repo>`.
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// The full ref proposed, e.g. `refs/heads/topic`.
        #[arg(long, value_name = "REF")]
        source: String,
        /// The full ref it would merge into, e.g. `refs/heads/main`.
        #[arg(long, value_name = "REF")]
        target: String,
        #[arg(long, value_name = "TEXT")]
        title: String,
        #[arg(long, value_name = "TEXT", default_value = "")]
        body: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// List a repository's pull requests. Needs `read`.
    List {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        /// `open`, `merged`, `closed` or `all`.
        #[arg(long, value_name = "STATE", default_value = "all")]
        state: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Show one pull request. Needs `read`.
    Get {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        number: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Close a pull request without merging it. Needs `write`.
    Close {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        number: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control issue …` — issues over the control plane, signed with the
/// caller's own key. The actor is who gunnar says the key is (`WhoAmI`).
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlIssueCommand {
    /// Open an issue; prints the number the fold assigned. Needs `write`.
    Open {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(long, value_name = "TEXT")]
        title: String,
        #[arg(long, value_name = "TEXT", default_value = "")]
        body: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Comment on an issue. Needs `write`.
    Comment {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        number: u64,
        #[arg(long, value_name = "TEXT")]
        body: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Close an issue, naming why. Needs `write`.
    Close {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        number: u64,
        #[arg(long, value_name = "TEXT", default_value = "")]
        reason: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// List a repository's issues. Needs `read`.
    List {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Show one issue and its whole thread. Needs `read`.
    Get {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        number: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control review …` — a pull request's review over the control
/// plane, the events signed with the caller's own key.
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlReviewCommand {
    /// The review: comments and who approves what, each approval re-checked by
    /// gunnar. The same as `gunnar control show`. Needs `read`.
    Get {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        pull: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Comment on a pull request — on one line of one file with `--path`,
    /// `--line` and `--blob`, or on the whole pull request without. Needs
    /// `write`.
    Comment {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        pull: u64,
        #[arg(long, value_name = "TEXT")]
        body: String,
        /// The file the comment anchors to.
        #[arg(long, value_name = "PATH", requires_all = ["line", "blob"])]
        path: Option<String>,
        /// The line in that file (1-based).
        #[arg(long, value_name = "N")]
        line: Option<u32>,
        /// The blob the line is in, so the comment follows the code.
        #[arg(long, value_name = "OID")]
        blob: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Approve a specific commit — the one `gunnar control merge` will move
    /// the target to. Needs `write`.
    Approve {
        #[arg(value_name = "NS/REPO")]
        repo: String,
        #[arg(value_name = "N")]
        pull: u64,
        #[arg(long, value_name = "OID")]
        commit: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control events …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlEventsCommand {
    /// **Print the live feed**, one line per event, until interrupted (or
    /// `--count` events). With a server-wide `read`: everything, narrowed by
    /// the flags. Without one: naming no `--namespace` watches your own
    /// accounts (and accounts whose events are public); naming some asks for
    /// exactly those, and gunnar refuses an account you may not watch.
    Watch {
        /// An account to watch. Repeatable.
        #[arg(long, value_name = "NS")]
        namespace: Vec<String>,
        /// One repository, `<namespace>/<repo>`. Repeatable.
        #[arg(long, value_name = "NS/REPO")]
        repo: Vec<String>,
        /// Only these kinds, e.g. `ref-updated`, `repo-created`,
        /// `pull-request-opened`. Repeatable.
        #[arg(long, value_name = "KIND")]
        kind: Vec<String>,
        /// Stop after this many events (the stream's own prologue not counted).
        #[arg(long, value_name = "N")]
        count: Option<u64>,
        /// Resume after this sequence number, as far as the server still holds.
        #[arg(long, value_name = "SEQ", default_value_t = 0)]
        from_seq: u64,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control grant list` — who holds what.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlGrantCommand {
    /// **Every grant on the server, per principal** — the answer to "what can
    /// this principal see?".
    ///
    /// One line per grant under the principal that holds it: the scope
    /// (`server`, `namespace:<name>`, `repo:<namespace>/<repo>`) and the
    /// level. A principal's OWN account does not appear: everyone is admin of
    /// `<their-name>/…` by construction, and that is not a grant anybody gave
    /// or can revoke.
    List {
        /// Only this principal's grants.
        #[arg(long, value_name = "ID")]
        principal: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control tunnel …` — a box's WireGuard tunnel address.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlTunnelCommand {
    /// What the box holds: its WireGuard public key, its tunnel address, and
    /// whether `wg0` is up (`Bootstrap.AssignTunnelAddress` with no address).
    Show {
        /// The box's control plane.
        #[arg(
            long,
            value_name = "HOST:PORT",
            conflicts_with = "ssh",
            required_unless_present = "ssh"
        )]
        at: Option<String>,
        /// A SEALED box's SSH door instead (`serve --no-grpc`): the
        /// `gunnar-tunnel-address` verb, as a gunnar-admin (`-i`).
        #[arg(long, value_name = "HOST:PORT")]
        ssh: Option<String>,
        /// The gunnar-admin key for `--ssh`.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
    /// **Give a fresh box its tunnel address and register it on the hub.**
    /// Idempotent: the same box gets the same address back.
    Enrol {
        /// The box's control plane (gRPC). A fresh box takes the address on
        /// `Bootstrap.AssignTunnelAddress`, an enrolled one on
        /// `Admin.SetTunnelAddress` (needs `-i`).
        #[arg(
            long,
            value_name = "HOST:PORT",
            conflicts_with = "ssh",
            required_unless_present = "ssh"
        )]
        at: Option<String>,
        /// A SEALED box instead (`serve --no-grpc --admin-key …`, no control
        /// plane): its SSH door, `gunnar-tunnel-address`, as the gunnar-admin
        /// whose key `-i` names.
        #[arg(long, value_name = "HOST:PORT")]
        ssh: Option<String>,
        /// The site's tunnel subnet, `a.b.c.d/prefix`. Its first host is the
        /// hub's and is never handed out.
        #[arg(long, value_name = "CIDR")]
        pool: String,
        /// The site's ledger of which box holds which address (created if absent).
        #[arg(long, value_name = "PATH")]
        ledger: PathBuf,
        /// The hub's WireGuard config; the box's `[Peer]` is appended once.
        #[arg(long, value_name = "PATH")]
        hub_peers: PathBuf,
        /// A name for the box in the ledger and the hub's comment (default: `--at`).
        #[arg(long)]
        label: Option<String>,
        /// A gunnar-admin key: for `--ssh`, and for a gRPC box PAST its
        /// bootstrap window (then the address goes over `Admin.SetTunnelAddress`).
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
}

/// `gunnar control twin …` — the twin, and the restore from it.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlTwinCommand {
    /// **Make a FRESH box a twin** (`Bootstrap.BecomeTwin` — refused once
    /// anything is enrolled, and taken only under a credential from a key the
    /// box was FED: the installer's admin, an admin principal of its feed, or
    /// `--restore-pubkey`; h-bootstrap-open). Prints the twin's public key:
    /// enrol it on the primary as a machine in group `twin`.
    Join {
        /// The fresh box's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// The primary's control plane, as the TWIN reaches it.
        #[arg(long, value_name = "HOST:PORT")]
        primary: String,
        /// A key the fresh box was fed (it signs the call).
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
    /// **Give a twin its mutual-TLS credential** — the pairing step the one
    /// medium cannot carry. Asks the TWIN for a signing request (its key is
    /// minted there and never leaves), has the PRIMARY's issuer sign it for
    /// `--principal` (Admin; the principal must be in group `twin`), and hands
    /// the certificate and the issuer to pin back to the twin. Run it after the
    /// twin's key is enrolled on the primary. Run it again under the same
    /// issuer to RE-ISSUE it, without a shell on either box.
    Certify {
        /// The twin's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// The primary's control plane, as THIS machine reaches it.
        #[arg(long, value_name = "HOST:PORT")]
        primary_at: String,
        /// The twin's principal on the primary.
        #[arg(long, default_value = "twin-box")]
        principal: String,
        /// An admin key on the primary.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
    /// **Restore a fresh primary from its twin, over the wire**: the twin's
    /// `Mirror.LogFetch` piped into the fresh box's `Bootstrap.Restore`
    /// (no credential there — it is fresh). Restart the box afterwards.
    Replay {
        /// The twin's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        twin: String,
        /// The fresh box's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        into: String,
        /// The key to read the twin's log with (an admin of the set).
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        /// **Do not tell the box how much is coming, so it cannot refuse for room.**
        ///
        /// By default the replay announces the twin's log size and the box checks its own
        /// data set against it before the first byte lands, refusing by name when it is
        /// short — the restore-onto-smaller-hardware case. That check is deliberately
        /// conservative: it asks for twice the LOG, and a long-lived box's log is larger
        /// than the stores it builds (superseded documents, deleted packs — what
        /// `twin retain` exists to trim). So a legitimate restore of an old, busy box onto
        /// the same hardware can be refused.
        ///
        /// This says "I have looked at the numbers in the refusal and this set holds it".
        /// The restore then behaves as it always did: if the volume really does fill, the
        /// apply fails mid-stream, the box stays `restoring`, and the replay is resumable
        /// onto a bigger set. It is never a silent short restore either way.
        #[arg(long)]
        ignore_room: bool,
        /// **Restore from this twin even though it is not a current copy.**
        ///
        /// By default the replay asks the twin how current it is and REFUSES
        /// when the answer is not "level with its primary, as of moments ago":
        /// a twin that is behind, that is not following, or whose primary last
        /// answered longer ago than the heartbeat's silent bound. The refusal
        /// carries both numbers (applied / the primary's head) and the AGE, so
        /// the operator is refusing or overriding on facts.
        ///
        /// The twin exists for the worst case, and the worst case is exactly
        /// when nobody has time to notice that the copy stopped following a
        /// week ago. Restoring from a stale twin is sometimes still the right
        /// call — it is the only copy there is — so this says "I have read the
        /// numbers in the refusal and I want this copy anyway". It is never a
        /// silent stale restore either way.
        #[arg(long)]
        stale_ok: bool,
        /// **Take the replay although the twin no longer holds its oldest entries.** A twin
        /// that trimmed its log (`twin retain`) and keeps no snapshot of what it trimmed
        /// cannot give those entries back; by default the replay refuses by name, saying how
        /// many and what goes with them (DATA-SET-GROWTH-FLOW §2.1), before a byte streams.
        #[arg(long)]
        accept_lost: bool,
        /// **How long a twin that was LEVEL at its last answer may have heard nothing from its
        /// primary before plain replay refuses it** (`--stale-ok` then required). Default 24 h.
        ///
        /// From the twin, a primary that died and a network cut to a primary still alive and
        /// taking writes look the same: level, then silence. Minutes of that is main dying; a
        /// day of it is not something to assume. `now` refuses any silence; `<N>` seconds, or
        /// `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
        #[arg(long, value_name = "DURATION", default_value = "24h", value_parser = parse_max_silence)]
        max_silence: std::time::Duration,
        /// **The twin's old primary is fenced (or its machine stopped): replay beside it.**
        ///
        /// A twin that heard its primary within the last minute is following a box that is
        /// ALIVE; filling a fresh box from it would make two writable primaries, so plain
        /// replay refuses `not_fenced`. Fence the old primary first (`Mirror.SetRole` standby
        /// and `DemoteReplication`, or stop it) and say so here: a fenced primary still
        /// answers its twin, so the twin cannot tell. A primary re-laid at the same address
        /// reads `diverged` and needs no fence.
        #[arg(long)]
        primary_fenced: bool,
    },
    /// **Follow a primary.** Fetch every transaction it has that this data
    /// root has not, in order, apply it, keep polling. Needs server Admin on
    /// the primary: the log carries every sealed secret.
    Follow {
        /// The primary's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// This twin's data root — its own copy of everything, plus the log.
        #[arg(long, value_name = "DIR")]
        data_root: PathBuf,
        /// The private key to authenticate with.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        /// Seconds between polls.
        #[arg(long, default_value_t = 5, value_name = "SECS")]
        poll_secs: u64,
        /// One poll, then exit — a cron, a bench, a hand check.
        #[arg(long)]
        once: bool,
    },
    /// **Restore: replay a twin's log into a fresh data root.** The restored
    /// primary continues the same sequence.
    Restore {
        /// The twin's data root (its `txlog/` is what is replayed).
        #[arg(long, value_name = "DIR")]
        from: PathBuf,
        /// The fresh data root to rebuild into.
        #[arg(long, value_name = "DIR")]
        into: PathBuf,
    },
    /// **What a box's log says**: head, oldest retained, and on a twin what
    /// it has applied and whether it is caught up.
    Status {
        /// The box's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// The private key to authenticate with.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
    /// **Retention** (`Mirror.LogRetain`, server Admin): drop every whole
    /// segment of the PRIMARY's log below `--from` (default: everything up to
    /// the head). A twin that has not applied up to there takes a snapshot.
    Retain {
        /// The primary's control plane.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// The oldest sequence to keep; 0 = keep nothing older than the next append.
        #[arg(long, default_value_t = 0, value_name = "SEQ")]
        from: u64,
        /// **Retain past what a twin has applied** (`LogRetainRequest.allow_past_twin`). By
        /// default the primary refuses to drop a frame the lowest-reporting twin has not applied,
        /// naming both numbers. This says the twin is gone or the hole is the point (a drill);
        /// the audit trail records who said it.
        #[arg(long)]
        allow_past_twin: bool,
        /// The private key to authenticate with.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
    },
    /// **CATCH A TWIN UP THROUGH A RETENTION HOLE** — the one verb that drives
    /// `Mirror.SnapshotFetch` on purpose (`.nornir/twin-transaction-log.md`
    /// §Snapshot; the KVM row R8 in the same file).
    ///
    /// A twin that was away longer than the primary's retention window meets a
    /// HOLE: the next sequence it needs, `applied + 1`, is below the primary's
    /// `first_seq`, and no `LogFetch` can ever answer for it again
    /// (`mirror.proto`'s `LogFetch`: "`from` below `first_seq` is
    /// FAILED_PRECONDITION naming `first_seq`: the twin needs a snapshot, not a
    /// range"). The only way across is a full copy of the primary's state at one
    /// sequence, and this is the verb that takes one.
    ///
    /// **It freezes the primary, and that is why it is its own verb.** The
    /// producer holds `Log::freeze()` for the whole of `SnapshotFetch`
    /// (`crates/gunnar-grpc/src/service/mirror.rs:117`), so while the snapshot
    /// streams EVERY framed write on the primary is stopped — every push, every
    /// ref update, every control-plane mutation. A 44 GiB snapshot legitimately
    /// takes a long time and the freeze is the cost of a consistent `at`; it is
    /// never a cost to incur by accident. So:
    ///
    ///   * it REFUSES BY NAME when there is no hole (`no-hole`), because a twin
    ///     that is merely behind catches up with `twin follow`, which freezes
    ///     nothing;
    ///
    ///   * it reports how long the primary's writes were frozen, as a number;
    ///
    ///   * it bounds the wait: no piece for `--stall-secs` fails by name
    ///     (`stalled`) and drops the stream, which is what releases the freeze.
    ///
    /// Run it WHERE THE TWIN'S DISK IS, like `twin follow`: `--at` is the
    /// PRIMARY's control plane, `--data-root` is this box's own data root. The
    /// twin's own follower must not be running against that root at the same
    /// time.
    ///
    /// ```text
    /// gunnar control twin catch-up --at primary:50051 --data-root /data
    /// ```
    ///
    /// `--dry-run` establishes the hole and stops, so the decision to stop the
    /// primary's write path is made before it happens.
    CatchUp {
        /// **The PRIMARY's control plane** — the box the snapshot is taken
        /// from, and the box whose writes freeze while it streams.
        #[arg(long, value_name = "HOST:PORT")]
        at: String,
        /// **This twin's data root**, on this machine. The snapshot is staged
        /// in it (`txlog-snapshot-staging/`) and installed over it.
        #[arg(long, value_name = "DIR")]
        data_root: PathBuf,
        /// The private key to authenticate with: server Admin on the primary,
        /// or the twin's own machine key in group `twin` — a snapshot carries
        /// every sealed secret and every object.
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        /// **Give up when NO snapshot piece has arrived for this long.** The
        /// deadline is on no progress, never on the transfer: a slow primary
        /// that keeps sending pieces is never cut off. The default is the
        /// primary's own mirror-image guard
        /// (`gunnar_grpc::service::mirror::SNAPSHOT_STALL_TIMEOUT`, 120 s),
        /// which is what cuts a twin off that stops READING.
        #[arg(long, default_value_t = 120, value_name = "SECS")]
        stall_secs: u64,
        /// **How many times the whole stream may be restarted.**
        ///
        /// `SnapshotFetchRequest` has no fields (`mirror.proto:165`), so there
        /// is no cursor on the wire and a broken stream can only be taken
        /// again FROM THE BEGINNING — which re-freezes the primary from
        /// scratch. Each restart is announced. A stall is never retried.
        #[arg(long, default_value_t = 3, value_name = "N")]
        attempts: u32,
        /// Establish whether there is a hole and say so, then stop. Nothing is
        /// streamed and the primary is never frozen.
        #[arg(long)]
        dry_run: bool,
    },
}

/// `gunnar control replication …` — replication's standbys.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlReplicationCommand {
    /// **Add or replace a standby, by name.** Takes effect immediately and
    /// survives a reboot.
    ///
    /// Idempotent: the same `<name>` twice replaces rather than duplicates, so
    /// a retry over a flaky link cannot leave two standbys behind.
    Add {
        /// Your name for this standby. Every event, log line and status row
        /// identifies it by this rather than by its URL.
        name: String,
        /// Where it is: an SSH base — `ssh://git@standby:2222` — onto which
        /// `/<namespace>/<repo>.git` is appended, or a complete URL ending in
        /// `.git` for a `--repo`-scoped target.
        ///
        /// SSH only, and anything else is refused by name: the push half is a
        /// send-pack over the server's russh transport, which has no HTTP
        /// transport to dial with.
        remote: String,
        /// Scope it to one namespace instead of the whole server.
        #[arg(long, value_name = "NAME", conflicts_with = "repo")]
        namespace: Option<String>,
        /// Scope it to one repository, `<namespace>/<repo>`.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        /// **The deploy key the SERVER authenticates to the standby with**, as
        /// a path ON THE SERVER.
        ///
        /// A server has no ssh-agent and no interactive user. Without this the
        /// push falls back to the client key ladder, which on a server is
        /// almost never what was meant — and the server warns about it at
        /// every start-up.
        #[arg(long, value_name = "PATH")]
        identity_path: Option<String>,
        /// Override the `known_hosts` file for this target, as a path on the
        /// server.
        #[arg(long, value_name = "PATH")]
        known_hosts_path: Option<String>,
        /// Refuse an unknown host key instead of trusting it on first use.
        /// Off by default so a standby can be brought up without a
        /// provisioning step; on is right for anything that outlives an
        /// afternoon.
        #[arg(long)]
        strict_host_key: bool,
        /// **Which repositories, by visibility.** `all` (the default) is a
        /// standby: everything the scope covers. `public-only` is a browse
        /// replica's feed: a private repository is
        /// never pushed there, and one that goes private is DELETED there —
        /// which is why `public-only` also needs `--control`.
        #[arg(long, value_enum, default_value_t = ReplicationScopeArg::All)]
        scope: ReplicationScopeArg,
        /// The replica's control plane, `host:port`, REQUIRED with
        /// `--scope public-only`: the delete on a public → private flip is
        /// `Repos.Delete` over this address, signed with `--identity-path`.
        #[arg(long, value_name = "HOST:PORT", required_if_eq("scope", "public-only"))]
        control: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Remove a standby by name and stop replicating to it now.
    ///
    /// A name that is not configured is an error and not a quiet success:
    /// "removed" about a standby that was never there reads identically to
    /// "removed" about the one you meant, and the difference is whether your
    /// data is still going somewhere.
    Remove {
        name: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **DEMOTE this box: drop EVERY standby at once — "I am no longer a
    /// primary".**
    ///
    /// The failover primitive. When a primary dies and its standby is promoted
    /// (promotion is operational — a gunnar standby is already a full gunnar
    /// that serves reads and takes writes, so nothing on it flips), the
    /// recovered old box comes back up and serving but its
    /// `replication.json` still lists the box that is now the NEW primary. Its
    /// replicator then resumes pushing its now-stale state and
    /// **force-clobbers the new primary's post-failover writes** — MEASURED in
    /// `deploy/checks/failover-drill.sh`, where a client commit made against the
    /// promoted standby was overwritten back to the old primary's ref.
    ///
    /// Run this on the recovered box before re-attaching it: it clears the whole
    /// target roster in one write, so the box stops being a primary. Then point
    /// the NEW primary at it with `replication add` and `replication resync`, and
    /// the pair is whole again — replicating the other way.
    ///
    /// It does not touch the box's repositories or its own control plane: a
    /// demoted box still serves git and still takes writes. It has only stopped
    /// shipping a second copy anywhere. Idempotent — a box with no targets
    /// demotes to the same place, so it is safe to run again.
    Demote {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Make this box a BROWSE replica**: a
    /// read-only standby that also serves anonymous reads of public
    /// repositories, refuses every control-plane mutator, and CANNOT be
    /// promoted — not by `promote`, not by `abdicate --promote`, not by the
    /// passive heartbeat. Feed it from the primary with
    /// `replication add <name> <remote> --scope public-only --control <this
    /// box>:50051`. The way back is `standby`, which only narrows what it
    /// serves.
    Browse {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **ABDICATE: this master steps down cleanly, then hands off.**
    ///
    /// The voluntary counterpart to a crash. `demote` is what you run on a box
    /// that already died; `abdicate` is how a healthy master leaves without
    /// losing a write, in order:
    ///
    ///   1. **freeze** — this box stops taking client writes (role → standby),
    ///      so nothing lands here after the hand-off begins;
    ///   2. **flush** — every standby is resynced and this waits for the queues
    ///      to drain, so the standby has everything that was pending. The wait
    ///      is a poll that returns the instant every queue is empty, bounded by
    ///      a ceiling (default 30 s) so a dead standby cannot wedge the hand-off.
    ///      On a heavily loaded box a *healthy* standby's backlog can take longer
    ///      than 30 s; raise the ceiling with `GUNNAR_ABDICATE_FLUSH_SECS=<secs>`
    ///      in the serving box's environment (a blank/zero/garbage value keeps
    ///      the 30 s default);
    ///   3. **demote** — the roster is cleared, so this box can never later
    ///      clobber the new primary.
    ///
    /// Then, as abdication's LAST act, it promotes the new primary — IF you name
    /// where its control plane is with `--promote`. That is the takeover: the
    /// standby is told `SetRole(PRIMARY)` and begins taking writes. Without
    /// `--promote` the three local steps still run and the command prints the
    /// one `replication promote` line to run against the standby yourself —
    /// naming a new primary is a decision, so it is never silently assumed.
    ///
    /// **The traffic switch is separate and is infrastructure.** The front
    /// (DNS / the UpCloud DNAT) still has to point at the new primary; abdication
    /// makes the hand-off safe, it does not move the address.
    Abdicate {
        /// The NEW primary's control plane, `host:port` — the standby to promote
        /// as abdication's last act. Omit to run only the local freeze/flush/
        /// demote and be told the `promote` command to run yourself.
        #[arg(long, value_name = "ADDR")]
        promote: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **PROMOTE this box to a writable primary — the takeover flip.**
    ///
    /// A standby serves reads and, once promoted, takes writes. Run it on the
    /// standby after the old master abdicated or died: the box records that it
    /// is a primary (persisted, so it survives a reboot) and thaws the write
    /// freeze. Idempotent — promoting a box that is already a primary is a
    /// no-op that prints the same "primary" status.
    ///
    /// The old master's `abdicate --promote <this box>` runs exactly this for
    /// you; this verb is the manual half, and the one a passive-heartbeat
    /// standby would call for itself when it decides the master is gone.
    Promote {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **STANDBY: make this box read-only to clients.**
    ///
    /// A standby serves reads and accepts only its primary's replication push (a
    /// `Machine` identity); a direct `git push` from a person or a deploy key is
    /// refused with "push to the primary". This is how a second box is
    /// deliberately made a standby up front, so a client that is mis-pointed at
    /// it cannot leave a write the primary never sees. `promote` is the inverse.
    Standby {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// What is configured, and what each standby has actually done — including
    /// **"last successful replication: never"**.
    ///
    /// Cheap by default: it reports the roster, each standby's
    /// synced/failed/pending, the verdict word and each repository's copy state
    /// without opening a single repository store. The one expensive number —
    /// **how many refs live on this box alone** — is behind `--refs`, because
    /// counting it opens every repository's reference database.
    List {
        /// **Also count the refs a mirror does not carry**, per repository and
        /// server-wide. This opens every repository's reference database — one
        /// refold each on a cold box — so it is off unless you ask: reach for it
        /// when you want the durability shortfall, not on every status glance.
        #[arg(long)]
        refs: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **BACKFILL a standby with what it never got.**
    ///
    /// Adding a standby copies NOTHING. Replication rides the push bus, so a
    /// standby holds what was pushed after it existed — a repository's whole
    /// history arrives on its next push, and a repository nobody pushes to
    /// again never arrives at all. MEASURED on the reference appliance 2026-08-31: a
    /// standby configured against 22 existing repositories held **0** of them,
    /// and `alice/linux-mirror` had 3818 refs on the primary and none on the
    /// standby.
    ///
    /// This queues them. It does not copy anything itself: every byte still
    /// moves on the standby's own background worker, through the same bounded
    /// queue and the same backoff a push uses, so a backfill never blocks a
    /// push and never waits for one. Watch it with `replication list` — the
    /// `pending` count falls, and the row names the repository in flight.
    ///
    /// **It is safe to run again.** A repository already outstanding coalesces;
    /// one the standby already has costs one ref advertisement and an empty
    /// command list. Interrupted half way — a reboot, a dead standby, a network
    /// that went away — the answer is to run it again.
    ///
    /// **The hash.** A standby PUSH-CREATES sha1 — the first-push prospect since
    /// 2026-09-20, because git advertises the object format before the client
    /// says anything and sha1 is what an un-negotiated client assumes. A
    /// repository of any OTHER hash replicates to nothing until it is
    /// pre-created on the standby with
    /// `control repo create <ns>/<repo> --hash sha256`. Those repositories are
    /// listed by name rather than left to fail quietly.
    Resync {
        /// The standby, by name, exactly as `list` prints it. A name that is
        /// not configured is an error: "resynced" about a standby that was
        /// never there reads identically to "resynced" about the one you meant.
        name: String,
        /// Only this repository, `<namespace>/<repo>`.
        ///
        /// Without it, every repository the standby's scope covers. With it,
        /// one — which is how a repository too large to ship in this maintenance
        /// window stays a separate decision from all the others.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        /// Everything the standby covers **except** these. Repeatable.
        ///
        /// The other half of `--repo`, and the one you actually reach for. On
        /// the reference appliance 24 of the 25 repositories are a few hundred
        /// megabytes together and one is ~8.7 GB and 13.9 M objects that
        /// projects **182–235 GiB** against a standby whose `/data` is
        /// **224.9 GiB**. Without this the only way to ship the safe
        /// twenty-four is one call each, and a box with no shell is exactly
        /// what this control plane is for.
        ///
        /// Ignored when `--repo` names one. An exclusion that matched nothing
        /// is reported rather than passed over: a typo and a real exclusion are
        /// the same command with very different outcomes.
        #[arg(long, value_name = "NS/REPO")]
        except: Vec<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },

    /// **Flush — the replication watermark.** Push everything pending to
    /// every standby, wait (bounded), and report whether the twin is caught
    /// up: the proof step 0 of a data-set growth needs before `/data` is
    /// unmounted. Changes no role and no roster.
    ///
    /// What it proves: every push the replicator saw is on the twin. What
    /// it does NOT prove: refs outside heads/tags, LFS objects, the control
    /// database, visibility, tombstones — and repositories nobody pushed
    /// since the twin existed, unless `--resync` enqueues them all.
    Flush {
        /// How long to wait for the queues to drain; 0 = the abdicate ceiling.
        #[arg(long, default_value_t = 0, value_name = "SECS")]
        timeout_secs: u32,
        /// Enqueue EVERY repository first (a full backfill).
        #[arg(long)]
        resync: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control grow …` — the runtime data-set growth
/// (`DATA-SET-GROWTH-FLOW.md` §3, ruling 1: the appliance never goes down).
#[cfg(feature = "control")]
#[derive(Subcommand, Debug, Clone)]
pub enum ControlGrowCommand {
    /// **Begin a growth.** gunnar-admin. Drains the box (new pushes are
    /// refused with "gunnar.rs is growing its storage…"), unmounts `/data`,
    /// then WAITS for the outside actor to detach, enlarge and re-attach
    /// every member volume; when all are back and larger it grows in place,
    /// mounts and serves again. `grow status` follows it; `grow resume` says
    /// "look again".
    ///
    /// Mints an OPERATOR go-ahead (the signed fact D3 asks for): the
    /// gunnar-admin credential on this call is the approval. `--go-ahead`
    /// sends one minted elsewhere (monetize's, on a metered box) instead.
    Start {
        /// The per-member size to grow to, GiB; without it, whatever every
        /// member comes back larger at.
        #[arg(long, value_name = "GIB")]
        target_gib: Option<u64>,
        /// How long in-flight transfers may take to finish before the growth
        /// aborts with nothing changed (never kills a transfer).
        #[arg(long, default_value_t = 600, value_name = "SECS")]
        drain_timeout_secs: u32,
        /// How long `/data` may stay unmounted waiting for the members. Past it,
        /// with every member present, the set is mounted back at the size the
        /// members carry and the growth fails as `grow.unmounted_too_long`
        /// (alarmed on `Admin.Health`); with a member missing it keeps waiting,
        /// alarmed.
        #[arg(long, default_value_t = 3600, value_name = "SECS")]
        wait_members_timeout_secs: u32,
        /// A go-ahead file to send verbatim instead of minting an operator one.
        #[arg(long, value_name = "PATH")]
        go_ahead: Option<PathBuf>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// The growth's phase, the members and the journal. **Needs no
    /// credential** — the actor outside the box that owns the volumes reads
    /// this while the identity table is off the stripe.
    Status {
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Look again**: the members are back and larger. gunnar-admin.
    /// `--give-up` stops waiting, assembles what is there at whatever size it
    /// is, and serves.
    Resume {
        #[arg(long)]
        give_up: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control webhook …` — the event stream's outbound delivery, over the control
/// plane.
///
/// Each verb returns the endpoint roster AND the server's webhook signing
/// public key — the OpenSSH line a receiver pins to verify a delivery with
/// `ssh-keygen -Y verify`. The private half never leaves the box.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlWebhookCommand {
    /// **Add or replace an endpoint, by name.** Takes effect immediately and
    /// survives a reboot.
    ///
    /// Idempotent: the same `<name>` twice replaces rather than duplicates.
    Add {
        /// Your name for this endpoint. Every log line and status row names it
        /// by this rather than by its URL.
        name: String,
        /// Where deliveries are POSTed: `http://…` or `https://…`.
        url: String,
        /// Only deliver these event kinds (repeatable, or comma-separated),
        /// e.g. `ref.updated`. Omit for every kind. A name that matches no kind
        /// is refused — a filter that names nothing delivers nothing.
        #[arg(long, value_name = "KIND", value_delimiter = ',')]
        kinds: Vec<String>,
        /// Only deliver events about this namespace.
        #[arg(long, value_name = "NAME", conflicts_with = "repo")]
        namespace: Option<String>,
        /// Only deliver events about this repository, `<namespace>/<repo>`.
        #[arg(long, value_name = "NS/REPO")]
        repo: Option<String>,
        /// An HMAC shared secret, for a receiver written against GitHub's
        /// `X-Hub-Signature-256`. The `sshsig` is always sent and is the one to
        /// prefer; this is only for compatibility. Stored on the server, never
        /// shown again.
        #[arg(long, value_name = "SECRET")]
        secret: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// What is configured, plus the signing public key a receiver verifies
    /// deliveries with, and per-endpoint delivered/failed/dropped counters.
    List {
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Remove an endpoint by name and stop delivering to it now.
    ///
    /// A name that is not configured is an error, not a quiet success:
    /// "removed" about an endpoint that was never there reads identically to
    /// removing the one you meant.
    Remove {
        name: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control policy …` — the pre-receive gate, over the control plane.
///
/// **The asymmetry these close.** Since a merge started requiring a verified
/// signed approval naming the exact commit, the review gate has been real —
/// and a direct push to `main` walked straight past it, because the surface
/// that would have stopped it was reachable from nothing. `Policy.*` has been
/// served since `gunnar_core::PolicyStore` gave the document a home at
/// `refs/gunnar/policy`; these verbs are the half that lets anyone say so.
///
/// A rule is stored **per repository, keyed by ref pattern**, and the pattern
/// is a git-style glob over full ref names — `refs/heads/main`,
/// `refs/heads/release/*`. It is not a branch name: `main` matches no ref and
/// protects nothing, which is why `protect` refuses it by name.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlPolicyCommand {
    /// **Protect refs matching a pattern.** Upserted on the pattern, so the
    /// same pattern twice replaces rather than accumulates.
    ///
    /// The defaults are the strict ones: with no flags at all a matching ref
    /// cannot be force-pushed and cannot be deleted. Each `--allow-*` gives one
    /// of those back, which makes the permissive choice the one that had to be
    /// typed.
    Protect {
        /// `<account>/<name>`.
        path: String,
        /// A git-style glob over FULL ref names — `refs/heads/main`,
        /// `refs/heads/release/*`. Not a branch name.
        pattern: String,
        /// Permit a non-fast-forward update. Off by default.
        #[arg(long)]
        allow_force_push: bool,
        /// Permit deleting a matching ref. Off by default.
        #[arg(long)]
        allow_delete: bool,
        /// Every new commit must carry a valid ssh-sig from a key registered
        /// to its pusher.
        #[arg(long)]
        require_signature: bool,
        /// Only these principals may push here. Repeatable. Empty — the
        /// default — means "any principal with WRITE".
        #[arg(long, value_name = "ID")]
        principal: Vec<String>,
        /// Reject an object larger than this BEFORE it lands. 0 is no limit.
        #[arg(long, value_name = "BYTES", default_value_t = 0)]
        max_object_bytes: u64,
        /// Reject a push carrying more objects than this. 0 is no limit.
        #[arg(long, value_name = "N", default_value_t = 0)]
        max_objects_per_push: u64,
        /// A merge into a matching ref does not count the pull request
        /// AUTHOR's own approval. Off by default: a solo operator must stay
        /// able to merge.
        #[arg(long)]
        forbid_self_approval: bool,
        /// A merge into a matching ref needs this many DISTINCT verified
        /// approvers of the exact tip. Approvals by the same principal count
        /// once. 0 keeps the baseline: one verified approval.
        #[arg(long, value_name = "N", default_value_t = 0)]
        required_approvals: u32,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Remove the rule on a pattern**, exactly as `list` prints it.
    ///
    /// A pattern that has no rule is an error and not a quiet success:
    /// "unprotected" about a rule that was never there reads identically to
    /// "unprotected" about the one you meant, and the difference is whether
    /// `main` is still guarded.
    Unprotect {
        /// `<account>/<name>`.
        path: String,
        /// The pattern, byte for byte as `list` prints it.
        pattern: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// What is protected here — and **"nothing"** said as the word.
    ///
    /// An unprotected repository and a repository whose rules failed to load
    /// are the same empty list and very different facts, so the empty case
    /// says which one it is.
    List {
        /// `<account>/<name>`.
        path: String,
        /// Only the rule on this pattern. Without it, every rule.
        #[arg(long, value_name = "GLOB")]
        pattern: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// The ref-name allowlist/denylist. **Deny wins.**
    RefNames {
        /// `<account>/<name>`.
        path: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Replace** the ref-name allowlist/denylist. Deny wins.
    ///
    /// Replace and not merge: the document is written whole, so passing
    /// neither flag CLEARS the policy rather than leaving the old one in
    /// place. That is the only version of this verb whose result can be
    /// predicted from the command line alone, and it says so before it writes.
    SetRefNames {
        /// `<account>/<name>`.
        path: String,
        /// A ref name may match one of these. Repeatable. Empty means "any
        /// name that is not denied".
        #[arg(long, value_name = "GLOB")]
        allow: Vec<String>,
        /// A ref name matching one of these is refused, even if it is also
        /// allowed. Repeatable.
        #[arg(long, value_name = "GLOB")]
        deny: Vec<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// The gates that run on this repository's push path, and **which of them
    /// this repository has configuration for**.
    ///
    /// `enabled` is not "compiled in": a gate with nothing configured cannot
    /// reject anything, and a list that claimed otherwise would be a UI
    /// rendering a guard that does not exist.
    Gates {
        /// `<account>/<name>`.
        path: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
}

/// `gunnar control repo …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlRepoCommand {
    /// Create a repository at `<account>/<name>`.
    ///
    /// New repositories are **sha256**, so a stock `git init` working
    /// tree cannot push to one — `git init --object-format=sha256`, or
    /// `--hash sha1` here.
    Create {
        /// `<account>/<name>`.
        path: String,
        /// The object hash. sha256 unless you have a sha1 client you cannot
        /// change.
        #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
        hash: HashArg,
        /// Readable by anyone, with no key at all.
        #[arg(long)]
        public: bool,
        /// **Take over a store already on disk at this name** (a catalog
        /// restored over a surviving data disk). Refused unless the store's
        /// hash marker matches `--hash`. Without it a create refuses the name
        /// BY NAME: "a store already exists at …; delete reclaims it".
        #[arg(long)]
        adopt: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// List repositories.
    List {
        /// Only this account's.
        #[arg(long, value_name = "ACCOUNT")]
        namespace: Option<String>,
        /// Only repositories whose `<account>/<name>` contains this,
        /// case-insensitively — gunnar filters, over everything you may see.
        #[arg(long, value_name = "TEXT")]
        query: Option<String>,
        /// Only repositories that are mirrors of an upstream.
        #[arg(long)]
        mirrors: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// Show one repository: its hash, default branch and visibility.
    Get {
        /// `<account>/<name>`.
        path: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Delete a repository. Without `--purge` the bytes are KEPT for a
    /// retention window and `repo undelete` puts it back.**
    ///
    /// The catalog row goes either way, so the name stops working immediately.
    /// What differs is the bytes: a tombstone holds them for
    /// `--retention-days` (30 by default) and a sweep reclaims them when it
    /// closes; `--purge` returns them now, in about two seconds, with no undo.
    ///
    /// A tombstone is still storage on the account until it expires —
    /// `repo list-deleted` shows what is held and what it costs.
    Delete {
        /// `<account>/<name>`.
        path: String,
        /// Required. Deleting a repository takes its name out of the catalog
        /// immediately, and a typo in a path is not distinguishable from an
        /// intention.
        #[arg(long)]
        yes: bool,
        /// **Reclaim the bytes NOW.** No window, no undelete.
        #[arg(long)]
        purge: bool,
        /// How long the bytes are kept. `0` means at the next sweep.
        #[arg(long, value_name = "DAYS")]
        retention_days: Option<u32>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Put a soft-deleted repository back**, inside its retention window.
    Undelete {
        /// `<account>/<name>`.
        path: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **End a retention window early.** There is no undo after it.
    Purge {
        /// `<account>/<name>`.
        path: String,
        /// Required, for the same reason `delete --yes` is.
        #[arg(long)]
        yes: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Maintain a live repository: seal, compact, drop the read cache.**
    ///
    /// The same maintenance `gunnar gc <path>` runs on the box, reachable from
    /// wherever the control plane is. It reclaims only what the server can
    /// re-derive — the lazily-exploded read cache goes, the commit-and-tree
    /// pool stays because that is the graph — so there is nothing to confirm
    /// and no `--yes`: the worst outcome is a slower next read.
    ///
    /// Needs `Write` on the repository, not `Admin`. A principal who may push
    /// to a repository may reclaim its cache.
    ///
    /// The server runs **one gc at a time for the whole box** and queues the
    /// rest, so this enqueues and then waits for its turn, printing what it
    /// freed. Asking twice for a repository already queued joins the job that
    /// is there rather than running a second one.
    Gc {
        /// `<account>/<name>`.
        path: String,
        /// Enqueue and return, printing the job instead of waiting for it.
        ///
        /// For a script that wants the gc started and is not going to sit
        /// through it. `repo gc-status <path>` reads the outcome afterwards.
        #[arg(long)]
        no_wait: bool,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **How a repository's gc is going, or how the last one went.**
    ///
    /// Needs only `Read`: seeing that maintenance ran is not asking for it.
    /// Says so plainly when the server is holding no job at all, which is a
    /// different answer from a gc that reclaimed nothing.
    GcStatus {
        /// `<account>/<name>`.
        path: String,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// What is deleted and not yet purged, with the bytes it is still costing.
    ListDeleted {
        /// Only this account's.
        #[arg(long, value_name = "ACCOUNT")]
        namespace: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Store directories with no catalog row and no tombstone — bytes
    /// nothing else can see, and names nothing else can free.**
    ///
    /// Every other reclaim path keys off a record: `repo delete` off the
    /// catalog row, `undelete`/`purge`/`list-deleted`/the sweep off a
    /// tombstone. A directory with neither is invisible to all of them, and
    /// `repo create` still refuses its name because the bytes are there.
    /// MEASURED on the appliance 2026-08-30: ~25-39 GB at `alice/linux`,
    /// stranded.
    ///
    /// The `HASH` column is the store's `OBJECT_FORMAT` marker, and `-` means
    /// the store predates it — that is the value to pass to `repo create
    /// --adopt --hash` if you want to KEEP the objects instead of reclaiming
    /// them.
    ListOrphans {
        /// Only this account's.
        #[arg(long, value_name = "ACCOUNT")]
        namespace: Option<String>,
        #[arg(long, short = 'i', value_name = "PATH")]
        identity: Option<PathBuf>,
        #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
        at: String,
    },
    /// **Reclaim an orphan store's bytes.** No window, no undo.
    ///

The file goes on; the console shows all of it.