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

nordisk/gunnar

crates/gunnar-client/src/error.rs

//! The client's error type.
//!
//! Two shapes are deliberately kept apart, because callers act on them
//! differently:
//!
//! * **[`Error::Rejected`]**: the remote spoke correctly and said *no*. A push
//!   that is rejected for a non-fast-forward is not a bug; it is the protocol
//!   working. It carries the per-ref detail so a CLI can print git's own wording.
//! * everything else covers transport, framing, or local repository trouble.
//!
//! `gix` raises a different concrete error type per operation (dozens of them),
//! and enumerating those here would couple this crate to gix's internal error
//! taxonomy for no gain. They are carried as [`Error::Repo`] with context
//! attached at the call site.
//!
//! # One rule, and every `X: X` in the client came from breaking it
//!
//! **A variant's `Display` names its own layer and nothing below it.**
//!
//! `gunnar`'s entry point prints `{err:#}`, and anyhow's alternate form walks
//! `source()` joining each link with `": "`. A variant that both carries a
//! `#[source]` *and* interpolates it into its own message is therefore printed
//! twice, once by itself and once by the walk. MEASURED 2026-08-05, five of nine
//! variants:
//!
//! ```text
//!   fetching ssh://host/x.git: repository: fetch (pack transfer): the pack was
//!   bad: fetch (pack transfer): the pack was bad
//! ```
//!
//! Fixed at the enum and at no call site: the five messages lost their `: {0}`
//! and kept their `#[source]`. Nothing about the text a user sees changes except
//! that the second copy is gone — `{:#}` renders the same sentence the doubled
//! `{}` used to. `Display` alone is now the layer label, which is why
//! `crates/gunnar-cli/src/main.rs` prints `{err:#}` and not `{err}`.
//!
//! The rule is guarded by construction rather than per variant:
//! [`tests::no_variant_display_repeats_its_own_source`] matches **exhaustively**
//! over `Error`, so a tenth variant does not compile until it has been listed
//! and checked.

/// Everything the client can fail with.
///
/// Every variant's `Display` is one layer only; print the chain with `{:#}` (or
/// [`chain`]). See the module docs for why.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The underlying transport failed: socket, pipe or SSH channel.
    #[error("transport i/o failed")]
    Io(#[from] std::io::Error),

    /// A pkt-line could not be framed or unframed. Almost always means the
    /// stream desynced earlier, or that what arrived was not a git stream at all
    /// (an SSH banner, a shell error message).
    #[error("pkt-line framing failed")]
    PktLine(#[from] gunnar_pktline::Error),

    /// The packfile could not be written.
    ///
    /// `porcelain` only: writing a pack is what `push`/`pack_source` do out of a
    /// worktree's loose objects. The wire half never writes one — a replicating
    /// server's storage engine emits it — so with the feature off `gunnar-pack`
    /// is not in the graph and this variant does not exist.
    #[cfg(feature = "porcelain")]
    #[error("packfile")]
    Pack(#[from] gunnar_pack::Error),

    /// The remote's bytes were framed correctly but did not say anything legal
    /// at this point in the conversation.
    #[error("protocol error: {0}")]
    Protocol(String),

    /// The remote refused the push. `unpack` is `receive-pack`'s verdict on the
    /// packfile itself (`"ok"` on success), and the per-ref detail is in
    /// [`crate::PushReport::refs`].
    #[error("remote rejected the push: {0}")]
    Rejected(String),

    /// The local repository could not be read or written.
    #[error("repository")]
    Repo(#[source] anyhow::Error),

    /// The SSH connection could not be established or authenticated.
    #[error("ssh")]
    Ssh(#[source] anyhow::Error),

    /// A refspec, URL or ref name was not usable.
    #[error("invalid argument: {0}")]
    Invalid(String),

    /// A refspec stock git refuses to parse, in **git's own words** so a user
    /// who has seen the message from `git` recognises it from `gunnar`:
    ///
    /// ```text
    ///   fatal: invalid refspec 'refs/heads/*'
    /// ```
    ///
    /// Kept apart from [`Error::Invalid`] for that reason alone — a prefix of
    /// *"invalid argument: "* in front of git's sentence is a third wording for
    /// something two implementations already agree on. See
    /// [`crate::fetch::check_fetch_refspec`].
    #[error("invalid refspec '{0}'")]
    InvalidRefspec(String),

    /// A mirror was asked for that is not registered, or the registry is
    /// unreadable.
    #[error("mirror: {0}")]
    Mirror(String),

    /// The identity the caller **named** cannot be used, and gunnar refused
    /// rather than authenticating as somebody else.
    ///
    /// Its own variant rather than [`Error::Ssh`] or [`Error::Invalid`] because
    /// this is the one failure a test about an access-control boundary must be
    /// able to tell apart from *"the server said no"*: both are exit 1, and
    /// only one of them proves the door is shut. See
    /// [`crate::ssh::resolve_identity`].
    #[error("ssh identity: {0}")]
    Identity(String),
}

impl Error {
    /// Wrap any failure as [`Error::Repo`] with a message. The shorthand every
    /// `gix` call site uses.
    ///
    /// `source` is taken by [`Display`](std::fmt::Display) rather than by
    /// `std::error::Error`, because gix's iterators hand back
    /// `Box<dyn Error + Send + Sync>` in some places and a concrete error type
    /// in others, and a bound that fits both without a second helper does not
    /// exist. The text is preserved; the machine-readable chain is not, and that
    /// is the trade being made knowingly.
    pub fn repo(context: impl std::fmt::Display, source: impl std::fmt::Display) -> Self {
        Error::Repo(anyhow::anyhow!("{context}: {source}"))
    }

    /// [`Error::Repo`] with the source's **whole cause chain** rendered.
    ///
    /// [`Error::repo`] keeps only the top line, and for gix that is routinely
    /// the least informative one in the chain. `gunnar.year` spent a session on
    ///
    /// ```text
    ///   week 7: fetch into the warm mirror: repository: fetch (pack transfer):
    ///   Failed to consume the pack sent by the remote
    /// ```
    ///
    /// which names the operation and not one thing about why it failed: the
    /// concrete cause — a pack entry, a slot table, a decode — is the
    /// `#[source]` underneath, and interpolating a source into a `thiserror`
    /// message renders only its top line (and doubles it under `{:#}`; see the
    /// module docs). With the chain rendered, the same failure reads
    ///
    /// ```text
    ///   … Failed to consume the pack sent by the remote: A pack entry could not
    ///   be extracted: The object d9a281a71ef544cb62ebdcc109bf61c8a4ca995b could
    ///   not be decoded or wasn't found
    /// ```
    ///
    /// and names the object, which is what turned that red into a diagnosis.
    ///
    /// Use this wherever the source is a concrete `std::error::Error`; keep
    /// [`Error::repo`] for the `Box<dyn Error + Send + Sync>` call sites, whose
    /// bound this cannot accept.
    ///
    /// The walk itself is [`chain`], written once so the two constructors cannot
    /// disagree about what a cause chain looks like (one implementation).
    pub fn repo_chain(
        context: impl std::fmt::Display,
        source: &(dyn std::error::Error + 'static),
    ) -> Self {
        Error::Repo(anyhow::anyhow!("{context}: {}", chain(source)))
    }

    /// [`Error::Repo`] from a bare message, for the cases with no inner error.
    pub fn repo_msg(message: impl Into<String>) -> Self {
        Error::Repo(anyhow::anyhow!(message.into()))
    }

    /// Wrap any failure as [`Error::Ssh`] with a message.
    pub fn ssh(context: impl std::fmt::Display, source: impl std::fmt::Display) -> Self {
        Error::Ssh(anyhow::anyhow!("{context}: {source}"))
    }
}

/// `err` and every `source()` under it, joined with `: `.
///
/// The one place this crate decides what a cause chain reads like.
///
/// Depth is bounded rather than trusted: an error graph with a cycle in it is a
/// bug someone else wrote, and hanging in the error path would hide whatever
/// was actually being reported. Sixteen is far past any real gix chain.
pub fn chain(err: &(dyn std::error::Error + 'static)) -> String {
    let mut out = err.to_string();
    let mut cursor = err.source();
    for _ in 0..16 {
        let Some(next) = cursor else { break };
        let text = next.to_string();
        // gix wraps some errors with a message identical to the one below it.
        // Printing it twice makes a chain look deeper than it is and says
        // nothing, which is the shape this function exists to remove.
        if !out.ends_with(&text) {
            out.push_str(": ");
            out.push_str(&text);
        }
        cursor = next.source();
    }
    out
}

impl From<anyhow::Error> for Error {
    fn from(e: anyhow::Error) -> Self {
        Error::Repo(e)
    }
}

/// The crate's result type.
pub type Result<T> = std::result::Result<T, Error>;

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Debug, thiserror::Error)]
    #[error("The object d9a281a7 could not be decoded or wasn't found")]
    struct Bottom;

    #[derive(Debug, thiserror::Error)]
    #[error("A pack entry could not be extracted")]
    struct Middle(#[source] Bottom);

    #[derive(Debug, thiserror::Error)]
    #[error("Failed to consume the pack sent by the remote")]
    struct Top(#[source] Middle);

    /// The red this exists for, stated as an assertion rather than as a memory:
    /// [`Error::repo`] renders the top line and **loses** the cause, which is
    /// the two lines a reader needs. Both halves are asserted in one test so
    /// the contrast cannot rot apart.
    ///
    /// Seen RED by making [`chain`] stop at the first link: *"the chain lost
    /// \"A pack entry could not be extracted\""*.
    #[test]
    fn repo_loses_the_cause_and_repo_chain_keeps_it() {
        let err = Top(Middle(Bottom));

        // `chain`, not `to_string`: a variant's own Display is its layer label
        // and nothing under it, which is the rule that stopped `X: X`.
        let flat = chain(&Error::repo("fetch (pack transfer)", &err));
        assert!(
            flat.contains("Failed to consume the pack"),
            "the top line went missing entirely: {flat}"
        );
        assert!(
            !flat.contains("d9a281a7"),
            "`repo` is documented as dropping the chain; if it stopped doing \
             that, `repo_chain` has no reason to exist: {flat}"
        );

        let chained = chain(&Error::repo_chain("fetch (pack transfer)", &err));
        for expected in [
            "fetch (pack transfer)",
            "Failed to consume the pack sent by the remote",
            "A pack entry could not be extracted",
            "d9a281a7",
        ] {
            assert!(
                chained.contains(expected),
                "the chain lost {expected:?}: {chained}"
            );
        }
    }

    /// A wrapper whose text repeats the layer under it must not be printed
    /// twice — the de-duplication is behaviour, so it is asserted.
    #[test]
    fn a_repeated_layer_is_not_printed_twice() {
        #[derive(Debug, thiserror::Error)]
        #[error("the same words")]
        struct Inner;
        #[derive(Debug, thiserror::Error)]
        #[error("the same words")]
        struct Outer(#[source] Inner);

        assert_eq!(chain(&Outer(Inner)), "the same words");
    }

    /// One of every variant, so the guard below covers the whole enum.
    ///
    /// The `match` is **exhaustive**, which is the whole trick: a tenth variant
    /// added to [`Error`] does not compile until it is named here and therefore
    /// checked. `#[non_exhaustive]` does not apply inside the defining crate, so
    /// this is the one place in the workspace where such a match is possible —
    /// and the reason this guard lives in `src/` rather than in `tests/`.
    fn one_of_every_variant() -> Vec<(&'static str, Error)> {
        let all = vec![
            (
                "Io",
                Error::Io(std::io::Error::new(
                    std::io::ErrorKind::BrokenPipe,
                    "the remote hung up",
                )),
            ),
            (
                "PktLine",
                Error::PktLine(gunnar_pktline::Error::ReservedLength),
            ),
            (
                "Pack",
                Error::Pack(gunnar_pack::Error::PackFull { declared: 3 }),
            ),
            ("Protocol", Error::Protocol("no such command".into())),
            ("Rejected", Error::Rejected("non-fast-forward".into())),
            (
                "Repo",
                Error::repo("fetch (pack transfer)", "the pack was bad"),
            ),
            ("Ssh", Error::ssh("connect", "auth failed")),
            ("Invalid", Error::Invalid("not a url".into())),
            (
                "InvalidRefspec",
                Error::InvalidRefspec("refs/heads/*".into()),
            ),
            ("Mirror", Error::Mirror("ns/repo is not registered".into())),
            (
                "Identity",
                Error::Identity("/no/such/key does not exist".into()),
            ),
        ];
        // Exhaustiveness, checked by the compiler rather than by counting: this
        // match has no wildcard arm, so the list above cannot fall behind the
        // enum without a build failure.
        for (name, e) in &all {
            let arm = match e {
                Error::Io(_) => "Io",
                Error::PktLine(_) => "PktLine",
                Error::Pack(_) => "Pack",
                Error::Protocol(_) => "Protocol",
                Error::Rejected(_) => "Rejected",
                Error::Repo(_) => "Repo",
                Error::Ssh(_) => "Ssh",
                Error::Invalid(_) => "Invalid",
                Error::InvalidRefspec(_) => "InvalidRefspec",
                Error::Mirror(_) => "Mirror",
                Error::Identity(_) => "Identity",
            };
            assert_eq!(*name, arm, "the sample list is mislabelled");
        }
        all
    }

    /// **No variant's `Display` may contain its own source's `Display`.**
    ///
    /// That is the whole of the `X: X` defect, stated as the rule that prevents
    /// it. `gunnar`'s entry point prints `{err:#}`, and anyhow's alternate form
    /// walks `source()` joining with `": "`, so a message that interpolates its
    /// own source is printed once by itself and once by the walk.
    ///
    /// Seen RED 2026-08-05 by restoring the `: {0}` on the five variants that had
    /// it — `Io`, `PktLine`, `Pack`, `Repo`, `Ssh` — each naming itself:
    ///
    /// ```text
    ///   Repo: Display is "repository: fetch (pack transfer): the pack was bad"
    ///   and already contains its source "fetch (pack transfer): the pack was
    ///   bad", so `{:#}` prints it twice
    /// ```
    ///
    /// Asserted on rendered output, not on the attribute: `{:#}` is what a user
    /// sees, so `{:#}` is what is measured.
    #[test]
    fn no_variant_display_repeats_its_own_source() {
        let mut with_a_source = 0usize;
        for (name, e) in one_of_every_variant() {
            let layer = e.to_string();
            let Some(source) = std::error::Error::source(&e) else {
                // No source: `{:#}` is the layer and there is nothing to double.
                assert_eq!(
                    format!("{:#}", anyhow::Error::new(e)),
                    layer,
                    "{name}: no source, so the alternate form must equal the plain one"
                );
                continue;
            };
            with_a_source += 1;
            let source = source.to_string();
            assert!(
                !layer.contains(&source),
                "{name}: Display is {layer:?} and already contains its source \
                 {source:?}, so `{{:#}}` prints it twice"
            );

            // And the applied output: the chain a user sees names the source
            // exactly once.
            let rendered = format!("{:#}", anyhow::Error::new(e));
            assert_eq!(
                rendered.matches(&source).count(),
                1,
                "{name}: `{{:#}}` renders {source:?} {} times:\n  {rendered}",
                rendered.matches(&source).count()
            );
            assert_eq!(
                rendered,
                format!("{layer}: {source}"),
                "{name}: the chain is not layer-then-source"
            );
        }
        // Anti-vacuity: if nothing had a source the loop above would assert
        // nothing at all, and this test would be the greenest guard in the crate.
        assert!(
            with_a_source >= 5,
            "only {with_a_source} variants carry a source, so the doubling this \
             test exists for could not be observed"
        );
    }
}