Skip to content

Reorganise Error-Handling around Exn #2351

@Byron

Description

@Byron

Tasks

  • Proof of concept
  • anyhow interaction (to keep the source-chain alive)
    • make cargo nextest --workflow run without --exclude gix-error by having multiple expectations there, depending on the set feature toggles.
  • replace thiserror with gix-error everywhere
  • Make it easy to differentiate between NotARepository and something went wrong opening it
  • In tests, use gix_error::Error when it helps with exn errors.

Related Issues

PRs

thiserror usages

 138 gix
  23 gix-pack
  22 gix-ref
  18 gix-filter
  12 gix-object
  11 gix-odb
  11 gix-index
  11 gix-config
  10 gix-transport
   9 gix-protocol
   8 gix-merge
   8 gix-diff
   7 gix-hash
   6 gix-submodule
   6 gix-credentials
   4 gix-revwalk
   4 gix-discover
   3 gix-url
   3 gix-traverse
   3 gix-pathspec
   3 gix-packetline
   3 gix-features
   2 gix-status
   2 gix-shallow
   2 gix-path
   2 gix-config-value
   2 gix-attributes
   1 gix-worktree-stream
   1 gix-worktree-state
   1 gix-refspec
   1 gix-quote
   1 gix-prompt
   1 gix-mailmap
   1 gix-lock
   1 gix-fs
   1 gix-dir
   1 gix-blame
   1 gix-bitmap
   1 gix-archive

Intermediate plan

IMPORTANT: Before batch 6, review and merge #2375. Otherwise it will probably conflict. Besides that, it has been waiting long enough.

Details

Plan: Migrate all crates from thiserror to gix-error

Context

The gitoxide project is migrating error handling from thiserror enum-based errors to gix-error's
Exn-based approach. This plan provides the dependency-ordered migration sequence so crates can be
ported leaf-first without breaking intermediate builds. The number after each crate name is the
approximate count of thiserror-derived error types/variants to migrate.

gix-archive (1) and gix-worktree-stream (1) are already migrated and not listed.

Migration patterns

Reference: gix-error/src/lib.rs module docs, .github/copilot-instructions.md

  • Replace thiserror dep with gix-error in Cargo.toml
  • pub type Error = gix_error::Exn<gix_error::Message>; (or appropriate specific type)
  • #[error("...")] variants become message("...") / message!("...")
  • #[from]/#[source] variants become .or_raise(|| message("context"))?
  • Callback bounds use bare Exn, function returns use specific types like Exn<Message>
  • At gix boundary: convert to gix_error::Error (implements std::error::Error)
  • Tests: pattern matches on variants become string assertions

Dependency-ordered migration batches

Crates within a batch can be migrated in parallel (no interdependencies).
Each batch depends only on batches above it being complete.

Batch 1 — Leaves (no deps on other migration-list crates)

Crate Count Notes
gix-hash 7 foundational, used everywhere
gix-url 3
gix-packetline 3
gix-features 3
gix-path 2
gix-attributes 2
gix-quote 1
gix-lock 1
gix-fs 1
gix-bitmap 1
gix-mailmap 1

Batch 2

Crate Count Depends on (migration-list only)
gix-object 12 gix-hash
gix-config-value 2 gix-path
gix-shallow 2 gix-hash, gix-lock
gix-refspec 1 gix-hash

Batch 3

Crate Count Depends on
gix-ref 22 gix-hash, gix-object
gix-filter 18 gix-object, gix-quote, gix-packetline, gix-attributes
gix-revwalk 4 gix-hash, gix-object
gix-pathspec 3 gix-attributes, gix-config-value
gix-prompt 1 gix-config-value

Batch 4

Crate Count Depends on
gix-traverse 3 gix-hash, gix-object, gix-revwalk
gix-config 11 gix-config-value, gix-ref
gix-credentials 6 gix-url, gix-prompt, gix-config-value
gix-discover 4 gix-ref

Batch 5

Crate Count Depends on
gix-index 11 gix-hash, gix-bitmap, gix-object, gix-traverse
gix-transport 10 gix-url, gix-packetline, gix-credentials, gix-quote
gix-worktree-stream -- already migrated
gix-submodule 6 gix-pathspec, gix-refspec, gix-config, gix-url

Batch 6

Crate Count Depends on
gix-diff 8 gix-hash, gix-object, gix-filter, gix-index, gix-pathspec, gix-attributes, gix-traverse
gix-protocol 9 gix-transport, gix-hash, gix-shallow, gix-ref, gix-credentials, gix-object, gix-revwalk, gix-lock, gix-refspec
gix-dir 1 gix-index, gix-discover, gix-pathspec, gix-object
gix-worktree-state 1 gix-object, gix-index, gix-filter
gix-archive -- already migrated

Batch 7

Crate Count Depends on
gix-pack 23 gix-object, gix-traverse, gix-diff, gix-hash
gix-merge 8 gix-hash, gix-object, gix-filter, gix-diff, gix-revwalk, gix-index
gix-status 2 gix-index, gix-hash, gix-object, gix-filter, gix-pathspec, gix-dir, gix-diff
gix-blame 1 gix-revwalk, gix-diff, gix-object, gix-hash, gix-traverse

Batch 8

Crate Count Depends on
gix-odb 11 gix-hash, gix-object, gix-pack

Batch 9 — Top-level

Crate Count Notes
gix 138 Porcelain crate. Uses gix::Error = gix_error::Error (implements std::error::Error). Largest migration, likely needs multiple sessions.

Per-crate migration checklist

For each crate:

  1. Cargo.toml: replace thiserror with gix-error
  2. Find all #[derive(thiserror::Error)] enums
  3. Replace with pub type Error = Exn<Message> (or appropriate type)
  4. Convert each variant per the patterns in gix-error/src/lib.rs "Migrating from thiserror"
  5. Update call sites: Err(Error::Variant)Err(message("...").raise())
  6. Update #[from]/#[source] call sites to use .or_raise(|| message("..."))?
  7. Update tests: enum matching → string assertions
  8. cargo check -p <crate> and cargo test -p <crate>
  9. Check downstream crates still compile (especially the next batch)

Verification

After each batch:

  • cargo check -p <crate> for each migrated crate
  • cargo test -p <crate> for each migrated crate
  • cargo check -p gix to catch downstream breakage early

GenAI Notes

Refine this prompt for better results, going one crate at a time.

In the CRATENAME, replace thiserror with gix-error after reading the documentation of gix-error/src/lib.rs carefully to know how to use gix-error correctly.

Actually, genAI isn't good at this, it just doesn't get it and creates a convoluted mess.
What is can do is turn thiserror into the manual implementation, but that's not super useful to start with, and I'd argue that one can do this better by hand.
Fair enough, it's my daily night task.

Benefits of the exn crate compared to thiserror/anyhow

The benefits of exn:

  • it's small at ~300 SLOC (anyhow has 14k)
  • it doesn't use proc-macros and has 0 dependencies (thiserror has 3 or 4 heavy ones)
  • it doesn't leak out of the crate, error types are hand-implemented structs or enums
    • Actually it does leak out, as the examples show an exn::Result which hides the Result<(), Exn<ErrorType>>
  • Call locations by default, without overhead or full backtraces

Disadvantages compared to thiserror

  • The Exn type is exposed in the typesystem, it wraps the actual type.
    • This can be hidden with exn::Result, and is very common also for anyhow::Result in applications. It's not common in plumbing crates, but I feel strongly that hiding it will be enough, with benefits clearly outweighing the disadvantage of marrying gix- with exn in that way.
    • If that's ever a problem, it can be moved into gix-errors even, and maybe that is what should be done to gain a little distance.

Benefits of the exn error handling style

  • sources of errors are gathered automatically
  • error chains/trees are searchable by downcasting
  • errors are organised by their value for the caller, and not by what went wrong

Of course, the presentation of errors, can be adjusted, but this is completely controlled by the calling application, and gix could provide its own application errors as utility if it wanted to (probably not).

Basic Example
// Copyright 2025 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! # Basic Example - Error Handling Best Practices
//!
//! This example demonstrates the recommended patterns for using `exn`:
//!
//! 1. **Define Error Types Per Module** - Each module has its own error type. The type system
//!    enforces proper error context via `or_raise()`.
//!
//! 2. **Don't Chain Errors Manually** - Unlike traditional error handling, you don't need `source:
//!    Box<dyn Error>` in your types. The `exn` framework maintains the error chain automatically.
//!
//! 3. **Keep Errors Simple** - Use `struct Error(String)` by default. Only add complexity (enums,
//!    fields) when needed for programmatic handling.

use derive_more::Display;
use exn::Result;
use exn::ResultExt;
use exn::bail;

fn main() -> Result<(), MainError> {
    app::run().or_raise(|| MainError)?;
    Ok(())
}

#[derive(Debug, Display)]
#[display("fatal error occurred in application")]
struct MainError;
impl std::error::Error for MainError {}

mod app {
    use super::*;

    pub fn run() -> Result<(), AppError> {
        // When crossing module boundaries, use or_raise() to add context
        http::send_request("https://example.com")
            .or_raise(|| AppError("failed to run app".to_string()))?;
        Ok(())
    }

    #[derive(Debug, Display)]
    pub struct AppError(String);
    impl std::error::Error for AppError {}
}

mod http {
    use super::*;

    pub fn send_request(url: &str) -> Result<(), HttpError> {
        std::fs::File::open("does not exist")
            .or_raise(|| HttpError(format!("Failed to open {url}")))
            .map(|_| ())
    }

    #[derive(Debug, Display)]
    pub struct HttpError(String);
    impl std::error::Error for HttpError {}
}

// Error: fatal error occurred in application, at examples/src/basic.rs:34:16
// |
// |-> failed to run app, at examples/src/basic.rs:49:14
// |
// |-> Failed to open https://example.com, at examples/src/basic.rs:63:14
// |
// |-> No such file or directory (os error 2), at examples/src/basic.rs:63:1

Needed in exn

Things I noticed when porting

  • Good debug printing so we get something akin to failed to create index: Git(FetchDuringClone(PrepareFetch(RefMap(Handshake(Transport(Io(Custom { kind: Other, error: "error sending request for url (https://github.com/rust-lang/crates.io-index/info/refs?service=git-upload-pack)" })))))))
    • Be sure this contains call locations, allowing people to help themselves more easily.
  • Validate that interop with anyhow, so that error chains work correctly.
    • Actually, gix-error would have to have a feature (default on via gix) to auto-setup an error chain and completely dissolve Exn.
    • Chained parts should always be linked-lists, until they can't be, or the 'chain' feature is set.
    • Actually, let's do it with an into-anyhow feature that uses publicly accessible methods to convert into an anyhow::Error.
  • Error iterator similar to iter_chain() or sources(). Note that source() has been converted into SourceError
  • Error must not loose call locations, try to keep them by using frames.

Metadata

Metadata

Assignees

Labels

C-tracking-issueAn issue to track to track the progress of multiple PRs or issues

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions