Skip to content

Convert the plumbing crates away from thiserror#2716

Draft
Amey Pawar (ameyypawar) wants to merge 58 commits into
GitoxideLabs:mainfrom
ameyypawar:gix-error-batch1
Draft

Convert the plumbing crates away from thiserror#2716
Amey Pawar (ameyypawar) wants to merge 58 commits into
GitoxideLabs:mainfrom
ameyypawar:gix-error-batch1

Conversation

@ameyypawar

@ameyypawar Amey Pawar (ameyypawar) commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Originally the batch-1 PR; scope grew to the whole plumbing conversion as discussed — one feat! commit per crate (29 crates, leaf-first, gix-lock at the bottom), plus a trailing chore commit for the lockfile, migration plan, and adaptations in gitoxide-core, the CLI, gix's tests and gix-testtools. No merge commits, for cherry-picking.

Not in this PR (also noted in etc/plan/gix-error.md):

  • gix-config, gix-submodule — kept at main; their conversions predate the lifetime-free config refactor and are best redone on top of it.
  • gix-protocol — waiting on feat: filters and partial cloning: initial support #2375.
  • gix-fs — no conversion yet.
  • gix — the finale; planned as a follow-up PR against current main (where ?-out-of-everything lands). Its code is untouched here — only its tests are adapted to the converted crates. Because of that, CI stays red on this PR: the workspace only compiles fully once gix converts.

Shapes, and when each was used

  • Exn<Message> / Exn<ValidationError> per the plan's default wherever nothing discriminates variants programmatically; validation/parsing paths carry the offending input via ValidationError.
  • Exn<concrete> (e.g. gix-lock's Failure) where callers discriminate cases but the gix-error benefits are wanted.
  • Concrete types with hand-written Display/Error/From impls (message texts unchanged) where the type is load-bearing — consumer #[from]/#[source] embeds, variant discrimination, Eq/PartialEq in tests, From<E> trait bounds. Mirrors the manual-error entries in Reorganise Error-Handling around Exn #2351; drops thiserror without replacement in those crates.

Review notes

  • Types that became Exn aliases no longer implement std::error::Error (by design); in-tree consumers keep working through Deref, propagation into Box<dyn Error>/anyhow uses .into_error(). TODO(review) markers sit at the known shortcomings and hand-preserved subtleties throughout — the two worth reading first: the io-cause gap through still-unconverted wrappers (gix-path/src/realpath.rs) and the hand-preserved transparent-Io semantics (gix-hash/src/io.rs).
  • gix-attributes: validation errors now render their input with debug quoting (: "foo"), and unquoting failures gain the line number with the cause chained (previously a static message) — that's what the larger test rewrite in its commit reflects.
  • Tests that matched variants of Exn-converted types now assert messages, per the plan; tests of concrete types kept their matches.
  • Known divergence from main in converted files: the allow↔expect lint-suppression drift from the lint pass (left for one sweep); the substantive changes stale conversion branches had reverted (gix-merge's binary-pick refactor, gix-odb's canonical PartialOrd + dropped allowances) are restored.

Validated per crate during conversion (crate suites + heaviest consumers, clippy's four feature combos with warnings denied, fmt). The stack is ordered so each crate lands after its converted dependencies; full workspace green arrives with the gix follow-up.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb20cf1226

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread gix-hash/src/io.rs
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(err) => err.source(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the wrapped I/O error as source

When bytes_of_file() or bytes_with_hasher() returns Error::Io for an open/read/interruption failure, this now exposes err.source() instead of the wrapped std::io::Error itself. For ordinary I/O errors source() is usually None (and for Error::other it skips the io::Error layer), whereas the previous #[error(transparent)] variant made the I/O error available as the source; callers using error chains/downcasting lose the actual I/O error and its kind. Return Some(err) for this variant.

Useful? React with 👍 / 👎.

@Byron

Copy link
Copy Markdown
Member

Thanks a lot!

When reading the review notes, which I find very valuable, I'd hope that these places to watch out for are marked in code in some way. Maybe TODO(review) or TODO where there is known shortcoming (for instance, when the io error isn't in the error chain anymore).

Besides that, and if you think the agent does a good job, I think this can continue to spike the whole task, the complete removal of thiserror. I'd be interested to see rewards to happen in gix which now can ? out of everything, as it is supposed to use gix-error::Error as anyhow-like type.

Lastly, something to think about, is to see which other standard-errors should exist as part of gix-error::Error. The idea is that it will provide utility functions like can_retry() and implement via error-tree analysis what previously was a huge match.

Thanks again for all your work!

@ameyypawar

Copy link
Copy Markdown
Contributor Author

Done on all counts:

  • gix-lock moved over from feat!: gix-error instead of thiserror in gix-lock #2711 as the bottom commits, as requested — its gix-ref adaptation now rides the feat! commit so every commit builds; the branch was rebuilt accordingly (PR description updated with the full 12-commit table).
  • TODO(review) markers are in at the known shortcomings: the io-cause gap through unconverted wrappers (gix-path/src/realpath.rs), the Exn alias that no longer implements std::error::Error for out-of-tree callers (gix-url/src/expand_path.rs), and the hand-preserved transparency (gix-hash/src/io.rs). I'll keep marking shortcomings that way as the conversion proceeds.

I'll continue with the remaining batches toward complete thiserror removal, leaf-first as before — and I like the can_retry() idea a lot: once enough of the tree is converted I'll survey what the big error-matches across the workspace actually discriminate on (spurious/retryable being the obvious first), and propose which standard errors and utility functions gix_error::Error should grow to replace them. The gix crate itself (? out of everything) stays the milestone to aim at.

Also: the earlier test-fixtures-windows (windows-latest) failure looks like the runner died mid-run (log never uploaded, comparison steps never executed; the arm leg passed in 16m on the same commit) — the new push will re-run it anyway.

@Byron

Copy link
Copy Markdown
Member

Fantastic, thank you!

Please do feel free to put the review markers into their respective commits, and to rewrite them at will. Some future work might want you to change bigger bits, and rewriting is fine with me. I will let you know once I start working on this PR so we don't step on each other feet.

One more note: If you think that converting multiple related crates at once would be beneficial, please feel free to do so. My plan is to look at the whole PR at some point, and try to find my way from there.

@ameyypawar
Amey Pawar (ameyypawar) marked this pull request as draft July 20, 2026 10:31
@Byron

Copy link
Copy Markdown
Member

It would also be helpful if the merge-commit could be removed for a clean patch-stack.

Besides that, I would have hoped for more lines to be removed, but maybe that changes once gix fully uses its crate::Error type.

@ameyypawar

Copy link
Copy Markdown
Contributor Author

The final result will land as a clean linear patch-stack (one commit per crate, no merge commits) — I'll flatten it as the last step before it leaves draft.

@ameyypawar

Copy link
Copy Markdown
Contributor Author

The branch is now the clean patch-stack: 29 feat!: remove thiserror from <crate> commits (leaf-first, gix-lock at the bottom) plus one trailing chore commit; no merge commits.

Deliberately not in this PR:

  • gix-config + gix-submodule — kept at main; their conversions predate the lifetime-free config refactor and are best redone on top of it (noted in the plan file).
  • gix-protocol (feat: filters and partial cloning: initial support #2375 pending) and gix-fs — no conversions yet.
  • gix itself — I'd convert it fresh against current main as a follow-up PR; that's where the ?-out-of-everything reward lands. Happy to pair on it.

Converted files still carry the allow↔expect lint-suppression drift from your lint pass (left for one sweep rather than touching every crate); the substantive bits my stale branches had reverted (gix-merge's binary-pick refactor, gix-odb's PartialOrd + allowances) are restored.

CI stays red until gix converts, as before.

@ameyypawar Amey Pawar (ameyypawar) changed the title Convert the remaining batch-1 crates away from thiserror Convert the plumbing crates away from thiserror Jul 20, 2026
@Byron

Sebastian Thiel (Byron) commented Jul 20, 2026

Copy link
Copy Markdown
Member

For this to really come to fruition, I think it will need gix to also remove thiserror. Right now, we are at +5k lines of code for this, and I'd hope that this can be redeemed on the caller side, gix, which should have a simple time just using gix::Error for everything.

All other plumbing crates that weren't converted should also be converted, no need to avoid conflicts. Conflicts are easy to fix nowadays.

Thanks again!

@Byron Sebastian Thiel (Byron) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a first look because these line counts didn't go into the right direction, and have some notes.

I'd think if done right, it won't be more than +30% of lines give or take, accounting for formatting and occasional double-usage of the same error message.

Maybe in some cases, it also makes sense to keep a custom Error type, which just isn't implemented with thiserror, which is what I think you already did as well.

In any case, don't let me disturb you, I know you are planning more sweeps. And even if not, it's something I can do too once I take over.

PS: I also saw that in many places, the underlying error is still a custom one that just doesn't use thiserror. Probably fine as first step, but the idea is to only do this when it's truly required or greatly beneficial.

Comment thread gix/src/clone/checkout.rs
repo.find_reference(reference_val)
.or_raise(|| gix_error::message("The HEAD reference could not be located"))?
.peel_to_id()
.map_err(gix_error::Error::from_error)?,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explicit mapping should never be explicitly required. But maybe you plan to do more sweeps to remove these.
peel_to_id() previously returned a thiserror type, and now that would be an Exn type, for which Error.

Also also, please optimise for size, so these common error types should be imported into the module to avoid long names.

Amey Pawar (ameyypawar) and others added 18 commits July 21, 2026 22:57
Converted on top of the lifetime-free parser refactor: all 13 error types --
the generic `lookup::Error<E>` with the bounds thiserror would have inferred,
the span error struct, the macro-generated name and value-name errors, section
header and value, the file rename and set-raw-value errors, includes, and the
four init errors -- become hand-written Display/Error/From impls with texts
preserved verbatim. The includes `Realpath` variant wraps gix-path's `Exn` and
exposes the inner error via `&**err`.
All seven error types become hand-written Display/Error/From impls with texts preserved verbatim: the five field-query errors whose `source`-named fields surface via source() without gaining a `From`, the init error whose transparent variants forward to the converted gix-config types, and the is-active-platform error whose variants wrap gix-pathspec `Exn` types and expose the inner error via `&**err`.

Also adapts gix-index's write error, which had been converted against the pre-conversion gix-lock error: `gix_lock::acquire::Error` is now `Exn<Failure>` and does not implement `std::error::Error`, so its source() exposes the inner `Failure` via `&**err`.
Converts 32 of the error types in `gix` to `pub type Error = gix_error::Error`, moving their construction to the call sites: mailmap, tag, pathspec, id-shorten, the four filter errors, diff options-init/new-rewrites/resource-cache, commit and commit-describe, the status index-worktree and into-iter errors, revision-walk, the tree-editor and tree-diff errors, config-overrides, reference-edits and reference-iter-init, worktree-proxy, repository-attributes, discover, clone-checkout, remote-save, repository-filter-pipeline and revision-spec-parse-single.

Message-bearing variants keep their text verbatim by re-attaching it at the call site with `or_raise`/`and_raise`, so causes stay reachable; transparent variants convert their concrete callees with `Error::from_error`, and callees that already return an erased or `Exn` type propagate with a plain `?`. Two tests that matched removed variants now assert the message instead.

This is a partial conversion: 110 `thiserror` derives remain in `gix`, so `gix` still depends on `thiserror` and CI stays red until the sweep finishes. Types whose variants are matched by callers -- notably those backing `IsSpuriousError` impls, and `open::Error` -- are deliberately left as custom enums. The explicit `Error::from_error` mappings are transitional and disappear as the remaining callees are converted.
`AsPathSpec` is a `clap` value parser whose `Value` is `BString`: it
validates that the argument parses as a pathspec, then stores the
*original* argument. While adapting to `gix_pathspec::parse::Error`
becoming `Exn<ValidationError>`, it was rewritten to return the parsed
`Pattern` and to use `Exn::into_error` as a terminal `map_err`.

That does not type-check: `Exn::into_error` yields a `gix::Error`, not a
`clap::error::Error`, and the closure no longer produced a `BString`.
The workspace therefore failed to build with E0308.

Restore the original shape, matching the sibling `CheckPathSpec` parser
directly below, which was adapted correctly: annotate the closure with
`gix::Error`, use `?` to discard the validated pattern, and return the
argument itself.
`gix_pathspec::parse()` now returns `Exn<ValidationError>`, which does not
implement `std::error::Error`, so `?` can no longer convert it into the
`anyhow::Error` the fuzz harness returns (E0277). Map it through
`Exn::into_error` first, matching how other call sites bridge into
error-trait-based contexts.

Note that the fuzz crates are not workspace members, so neither
`cargo check --workspace` nor `cargo clippy --workspace` builds them; only
the `Fuzzing` CI job does. All 35 of them now build.
The conversion replaced the `NotANormalComponent` variant, whose message
interpolated the path inline, with `ValidationError::new_with_input()`.
That renders as `<message>: <input>`, reordering user-visible text from

    Input path "../outside" contains relative or absolute components

to

    Input path contains relative or absolute components: "../outside"

The migration guide translates a formatted variant to the same wording it
had before, and the reordering broke assertions in `gix-fs`' own Windows-only
tests and in `gix-worktree-state`. Construct the message verbatim instead, so
every existing assertion holds unchanged.

Using `Path::display()` also avoids `gix_path::into_bstr()`, which is
`try_into_bstr().expect()` and panics on ill-formed UTF-16 on Windows — the
reason the input was previously routed through a fallible conversion here.
These three assertions were rewritten to match the reordered `Display`
output produced by `ValidationError::new_with_input()`. Now that the
message is constructed verbatim again, they match upstream unchanged.

This also realigns them with the five assertions in the `#[cfg(windows)]`
block of the same file, which were never updated because they do not
compile on Unix — the divergence that made `test-fixtures-windows` fail.
The sibling error in this file was restored to its original wording, but
this one still attached the offending bytes as `ValidationError` input,
which renders as a `: "…"` suffix the previous `IllegalUtf8` variant never
had. Nothing asserts that text, so it went unnoticed — but it is the same
user-visible divergence, and preserving it only where a test happened to
look is not a principle.

Construct it verbatim as well, and note in the comment that attaching the
input is the nicer shape, so it can be adopted deliberately rather than as
a side effect of the conversion.
Both `and_raise` call sites live inside `#[cfg(feature = "parallel")]`
blocks, so a non-parallel build imports the trait without using it and
warns. `just clippy` never sees this because it passes `--workspace`,
where feature unification pulls `parallel` back in; only the non-workspace
`cargo check --no-default-features --features small` in `just check`
resolves `gix` without it.

Gate the import rather than removing it, since the parallel build needs it.
Part of converting `gix` itself to `gix::Error`. `commit_graph_if_enabled::Error`
was a pure wrapper whose variants nothing matched, so it becomes an alias.

The config lookup reaches a still-concrete plumbing error and converts
explicitly with `Error::from_error`.
Part of converting `gix` itself to `gix::Error`. `blame_file::Error` was a
pure wrapper whose variants nothing matched, so it becomes an alias.

`commit_graph_if_enabled()` is already erased, so it propagates with a plain
`?`; the diff-cache and diff-algorithm lookups still return concrete plumbing
errors and convert explicitly with `Error::from_error`.
Part of converting `gix` itself to `gix::Error`. `diff_tree_to_tree::Error`
was a pure wrapper whose variants nothing matched, so it becomes an alias.

`diff_resource_cache::Error` is deliberately left concrete for now: erasing it
cascades into `status::tree_index`, `status::iter::Error` and `is_dirty`, and
`status::iter::Error` is discriminated in `status/index_worktree.rs`. Its call
sites therefore still convert explicitly, and will collapse to a plain `?` when
it converts.
Part of converting `gix` itself to `gix::Error`. `merge_base::Error`,
`merge_base_with_graph::Error`, `merge_bases_many::Error`,
`merge_base_octopus::Error` and `merge_base_octopus_with_graph::Error` all
become aliases; nothing matched their variants.

Their four message-bearing variants are reproduced verbatim at the sites that
used to construct them, so the text a caller sees is unchanged. Note this does
drop the structured `NotFound { first, second }` fields and the
`MissingCommit`/`NoMergeBase` distinction, which are now only recoverable from
the message — please say if either should stay addressable.
Replaces sixteen `thiserror` enums in `gix/src/repository/mod.rs` with
`pub type Error = gix_error::Error;`. All of them were wrappers that only
forwarded their sources, and a scan of `gix`, its tests, `gitoxide-core` and
the binaries found no code matching any of their variants — every reference
was a construction, which the call sites now perform directly.

This is the shape you asked for: erasing on the caller side removes far more
than it adds, and this file loses the bulk of it.

Left as concrete enums for now:

- `diff_resource_cache` and `index_from_tree`, because erasing either gives
  `status::tree_index::Error` a second `From<gix::Error>` and cascades into
  `status::iter::Error`, which is discriminated in `status/index_worktree.rs`.
- `branch_remote_ref_name`, `branch_remote_tracking_ref_name`,
  `upstream_branch_and_remote_name_for_tracking_branch`,
  `pathspec_defaults_ignore_case`, `index_or_load_from_head{,_or_empty}`,
  `worktree_stream`, `new_commit` and `new_commit_as`, which carry
  message-bearing variants whose context has to be re-attached at each call
  site rather than dropped.
Follows the erasure of the merge-related error types in `repository/mod.rs`.
`virtual_merge_base_with_graph`'s only message-bearing variant is reproduced
verbatim where it was constructed, so the text is unchanged.

Callees that are themselves erased propagate with a plain `?`
(`filter::Pipeline::options`, `blob_merge_options`, `merge_resource_cache`,
`commit_graph_if_enabled`); the rest still return concrete plumbing errors and
convert with `Error::from_error`. Keeping those two cases apart matters: using
`from_error` on an already-erased callee compiles and passes tests while
quietly nesting the error inside itself.
Completes the erasure batch. `find_tree()` still returns a concrete
`with_conversion::Error`, so it converts explicitly; `tree.edit()` returns the
already-erased `object::tree::editor::init::Error` and keeps its plain `?`,
which avoids nesting the error inside itself.
With the error types erased, the inner and outer types are identical, so
clippy's `needless_question_mark` fires on the `Ok(…)`/`?` pairs the previous
signatures needed. Return the expression directly.
Completes the previous commit: with the error types erased, inner and outer
types match, so clippy's `needless_question_mark` fires on the remaining
`Ok(…)`/`?` pairs in the merge-base, merge and tree-editing entry points.
Amey Pawar (ameyypawar) and others added 3 commits July 22, 2026 14:10
Part of erasing the transparent config error types. The leniency helper still
yields a concrete plumbing error, so it converts explicitly.

Note this commit alone does not build; it pairs with the erasure in
`config/mod.rs`. Delivered separately only because the tree API rejects
multi-file commits on this repository.
That change only compiles together with the erasure of
`config::ssh_connect_options::Error`, which is not on this branch yet, so it
left the tree unbuildable. Restore the original conversion and land both
together instead.
`merge::pipeline_options`, `merge::drivers`, `diff::pipeline_options` and
`ssh_connect_options` only forwarded their sources and nothing matched them,
so they become aliases; their call sites convert explicitly where the callee
is still a concrete plumbing error.

`command_context` was tried and reverted: `checkout_options::Error` and
`worktree_stream::Error` already embed the erased
`filter::pipeline::options::Error`, so erasing it gives them a second
`From<gix::Error>`, and `checkout_options` has to stay concrete because
`config/cache/access.rs` matches its variants. The reason is recorded in a
TODO next to the type.
@Byron

Sebastian Thiel (Byron) commented Jul 22, 2026

Copy link
Copy Markdown
Member

It feels like this PR is a bit troubled, and something that should help reduce scope is to focus on gix and its usage of gix::Error with direct error forwarding.
This way, at least, there should be a clear reduction of complexity.
In the plumbing crates, let's just leave the original expanded, hand-implemented error types for now, instead of bringing in gix-error::Exn there. That way the PR will be far easier to review and can merge quickly.

Per review feedback, the plumbing crates keep their expanded, hand-implemented
error types rather than adopting `gix-error::Exn`, which does not implement
`std::error::Error` and so would have forced out-of-tree callers to unwrap it
before propagating. `thiserror` stays removed, so `Display` and `Error` are
written out by hand.

This covers `gix-fs`, `gix-attributes`, `gix-pathspec`, `gix-url`, `gix-path`,
`gix-lock`, `gix-shallow` and `gix-prompt`. Several of them have to move
together: `gix-pathspec` reads the public `attribute` field off
`gix-attributes`' `name::Error`, `gix-url`'s tests propagate
`gix_path::realpath::Error`, and `gix-shallow`'s `write::Error` wraps
`gix_lock::commit::Error`, so neither of each pair compiles against the other's
erased form.

Every message is reproduced verbatim. The variants that were
`#[error(transparent)]` forward both `Display` and `source()`, those that
carried `#[from]` expose the error they hold and keep their conversion, and the
ones that wrapped an error without an attribute keep no source at all, matching
what the derive generated in each case.

Because the error types implement `Error` again, the call sites that bridged
through `gix-error` return to a plain `?`, `gix-ref` names `acquire::Error`
directly once more, and `gix` converts explicitly at its own boundary where it
erases.
…ger meets

`gix::discover::Error` is erased, so `clippy::result_large_err` does not fire
for `discover()` or `discover_opts()` any more. The expectation was already
removed from the former when `gix` started using `gix::Error`; merging picked
the latter back up from `main`, where the error type is still concrete, which
left it unfulfilled and failed the lint job.
The note claimed `Io` has no `From` conversion, but a hand-written
`From<std::io::Error>` sits below it and predates the `thiserror` removal: it
downcasts to recover an `UploadPack` error smuggled through `io::Error` before
falling back to `Io`. That is why the field was `#[source]` rather than
`#[from]` — the conversion could not be derived.
Nothing in the crate refers to it since its error types went back to
hand-written impls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants