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