Skip to content

Remove thiserror from every crate but gix, erasing error types at the gix boundary - #2716

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

Remove thiserror from every crate but gix, erasing error types at the gix boundary#2716
Amey Pawar (ameyypawar) wants to merge 73 commits into
GitoxideLabs:mainfrom
ameyypawar:gix-error-batch1

Conversation

@ameyypawar

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

Copy link
Copy Markdown
Contributor

Removes thiserror from every crate but gix. 67 of 68 crates are free of it; gix still has 42 error types concrete and keeps the dependency.

Per review feedback here, the conversion is three-tier rather than a uniform erasure:

  • Plumbing crates drop thiserror and keep concrete enums with hand-written Display/Error impls, no gix-error dependency. Eight crates already converted to Exn were reverted to this shape.
  • gix-error adopters — 14 crates use its types directly in their own error surface.
  • The gix boundary erases to pub type Error = gix_error::Error; where callers don't need to match variants. 101 types.

The 42 that remain each carry a TODO(review) naming the blockers that apply — callers matching variants, an E0119 spent slot on a parent enum, a generic parameter an alias can't express, or an E0117 orphan impl. Some are blocked several ways at once. etc/plan/gix-error.md has the detail, including a double-wrap trap where from_error on an already-erased callee nests one error inside another and still compiles and passes the suite.

gix::config::tree::Key::validate() is a public trait method, so erasing validate::Error changes what Key implementors have to satisfy — the only trait affected.

On size: the branch adds roughly 6,000 lines net, so it went up rather than down. That sits almost entirely in the plumbing crates, where dropping thiserror meant hand-writing the Display and Error impls it used to generate — gix-pack +770, gix-ref +669, gix-filter +494 and so on. gix and gitoxide-core sources together come to -318.

Linear history, no merge commit.

@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.

@ameyypawar
Amey Pawar (ameyypawar) force-pushed the gix-error-batch1 branch 2 times, most recently from 3402595 to 25c0ee0 Compare July 21, 2026 19:51
@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.

@ameyypawar
Amey Pawar (ameyypawar) force-pushed the gix-error-batch1 branch 2 times, most recently from ae76758 to dabff9a Compare July 22, 2026 17:07
@Byron

Copy link
Copy Markdown
Member

Thanks a lot! From what I can tell, there is only one manifest still needing thiserror, and that's gix and gitoxide-core. In gitoxide-core it can easily be removed in favor of gix-error::Error or gix::Error, thiserror has no business there.

For gix, it should be the same.

Is there anything in the way of finishing this? If not, I will be looking forward to giving it the last push, I will do that with priority.

Thanks again for all the time you put into this!

@ameyypawar

Copy link
Copy Markdown
Contributor Author

Nothing blocks you — my local commits sit on the PR tip as a clean fast-forward, and gix checks and tests pass.

On scope, so I don't oversell it: I've converted 17 of gix's ~90 error types to gix::Error so far (in repository, reference, submodule); ~74 thiserror derives still remain, so this is a partial start, not most of it. Six types I left concrete with a TODO(review) each — erasing them would give a parent enum that already #[from]s an erased type a second From<gix_error::Error>, which collides (E0119); the note on each names the exact pair.

Happy to push it as a head start, or to leave gix to you entirely if you'd rather do a clean sweep — your call, since you're taking the last push.

@Byron

Copy link
Copy Markdown
Member

I am not sure we talk about entirely the same thing, but from what I see, it should be possible to convert as much of gix and gitoxide-core as possible, and leave the rest with TODO's.

When done, and you think that's it reasonably, the PR can come out of draft which is my cue.

@ameyypawar
Amey Pawar (ameyypawar) force-pushed the gix-error-batch1 branch 4 times, most recently from 5bfea5f to bfda230 Compare July 27, 2026 10:00
@ameyypawar Amey Pawar (ameyypawar) changed the title Convert the plumbing crates away from thiserror Remove thiserror from every crate but gix, erasing error types at the gix boundary Jul 27, 2026
@ameyypawar

Copy link
Copy Markdown
Contributor Author

Ready for review.

gitoxide-core is done. In gix, 101 error types are erased to gix_error::Error and 42 are still concrete, each with a TODO(review) saying why. The four blockers, and a double-wrap trap worth knowing about, are written up in etc/plan/gix-error.md.

On the line count you raised: the branch adds roughly 6,000 net, so it went up rather than down. Nearly all of that is the plumbing crates, where dropping thiserror meant hand-writing the Display and Error impls it used to generate — gix-pack +770, gix-ref +669, gix-filter +494 and so on. gix and gitoxide-core sources come to -318 between them. So the caller side does reduce, just nowhere near enough to pay for the plumbing.

The 42 isn't a floor. The E0119 blocker depends on order: erasing a hub enum frees everything it pinned, which is how nine types that looked blocked turned into ten erasures.

Merge commit is gone — the history is linear now, and the tree hash is unchanged from before the rebase.

Two things worth flagging:

  • gix::config::tree::Key::validate() is a public trait method, so erasing validate::Error changes what Key implementors have to satisfy. It's the only trait affected.
  • from_error won't take Box<dyn Error + Send + Sync + 'static> since std's blanket impl is Sized-only, so seven sites bridge through io::Error::other. A from_boxed would drop all of them.

The branch is behind main and conflicts in Cargo.lock, gix-fs/Cargo.toml, gix-lock/Cargo.toml, gix/src/diff.rs and gix/src/filter.rs. Happy to rebase if you'd like.

I'm around and happy to pick up whatever's useful — more of gix, the conflicts, or anything that comes out of your review.

@ameyypawar
Amey Pawar (ameyypawar) marked this pull request as ready for review July 27, 2026 14:50
@Byron

Copy link
Copy Markdown
Member

Thanks a lot! A rebase would be appreciated, but is no must.

Amey Pawar (ameyypawar) and others added 26 commits August 2, 2026 18:44
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.
`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.
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.
Continue converting `gix` to its erased boundary type: thirteen error types
in `repository/` and `reference/` become `pub type Error = gix_error::Error`,
so callers `?` through them without matching variants.

Erased in `repository/mod.rs`: `new_commit`, `new_commit_as`,
`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`. Erased in
`reference/errors.rs`: `peel::to_kind`, `follow::to_object`, `head_id`,
`head_commit`, `head_tree`, `find`.

Four types that were targeted stay concrete because erasing them would embed a
second `gix_error::Error` in a parent enum and collide on `From` (E0119):
`diff_resource_cache`, `index_from_tree`, `index_or_load_from_head_or_empty`
(through the `status`/`worktree_stream` chains) and `head_tree_id` (through
`status::is_dirty`). Each carries a `TODO(review)` naming the collision.

Call sites that bridged through these types go to a plain `?` where the callee
is now erased, and to `.map_err(gix_error::Error::from_error)` where it stays
concrete. Every message on an erased variant is preserved verbatim at its
construction site via `or_raise`/`message!`.

`head_tree_id_or_empty` recovered its unborn-HEAD case by matching the nested
error structurally; since `head_commit`'s error is now erased, it intercepts
`peel_to_commit`'s still-concrete error and recovers `Unborn` via
`downcast_any_ref`, which works regardless of the `tree-error` feature.
Continue converting `gix` to its erased boundary type: four error types in
`submodule/errors.rs` become `pub type Error = gix_error::Error`, so callers
`?` through them: `open_modules_file`, `modules`, `is_active`, `fetch_recurse`.

`index_id` and `head_id` were targeted too but stay concrete: `submodule::status::Error`
already embeds the erased `status::into_iter::Error` (via `StatusIter`), and it
also has `#[from]` variants for both of these, so erasing either would give it a
second `From<gix::Error>` and collide (E0119). Each carries a `TODO(review)`.

The two custom messages on the erased variants — "Could not read '.gitmodules'
file" and "Could not find the .gitmodules file by id in the object database" —
are preserved verbatim at their construction sites via `or_raise`.
All three error types here go to `gix::Error`, which the crate reaches through
`gix`'s own re-export, so no new dependency is needed.

`repository::credential`'s and `pack::explode`'s types were private and are gone
entirely — their functions now produce `gix::Error` directly.
`pack::input_iteration::Error` is public and becomes an alias, which is breaking
for anyone matching its variants; nothing in the workspace does.

Of the ten messages these types carried, five are reproduced verbatim at the
sites that produce them. The other five had no producer: `pack::explode`'s `Io`
and `OdbWrite` could only be reached through `From` conversions that no code
path took, because `gix_object::write::Error` boxes those errors long before
they arrive, and `input_iteration`'s three belong to a type nothing references.

`gix` is now the only crate in the workspace that still depends on `thiserror`.
`diff::algorithm`, `diff::drivers`, `encoding`, `abbrev`, `transport` and
`transport::http` become `gix::Error`, so callers `?` through them.

Four more were targeted but stay concrete. Three because a parent enum already
embeds an erased error and a second one would collide on `From` (E0119):
`exclude_stack` through `dirwalk`, `protocol::allow` through `remote::connect`
and `commit_signature` through `clone::fetch`. `attribute_stack` is blocked the
same way, through `checkout_options`. `stat_options` is a different case:
nothing embeds it, but `checkout_options()` maps its two variants across by
hand, which an erased type cannot support. Each carries a `TODO(review)` saying
which of the two it is.

The top-level `config::Error` gained a note as well: erasing `abbrev` spent the
one slot it has, so no other `#[from]` member of that enum can be erased without
colliding.

`key::Error` is deliberately untouched. It is generic over its source and two
const parameters, and fourteen aliases are built on it, so it has no erased
form.

Every message on an erased type is reproduced verbatim where it is produced.
That includes the context `transport::Error` used to add when an `http::Error`
crossed into it, which the erasure turned into an identity conversion; it is
restored at all twelve crossings.

`diff_algorithm()` could no longer match the `Unimplemented` variant to decide
whether a lenient configuration should fall back, so it re-derives the same
condition from the input instead, and a test now covers both outcomes.
`object::conversion`, `object::find::existing::with_conversion`,
`object::write` and `object::peel::to_kind` become `gix::Error`.

Four others were targeted and stay concrete. Erasing `find` or `commit` would
give a parent enum a second `From<gix::Error>` and collide: `update::Error`
already embeds the erased `reference::find::Error`, and
`submodule::head_id::Error` the erased `reference::head_commit::Error`.
`try_into` is destructured by two callers that read its fields, in
`object::commit` and in the revision-spec delegate. `blob::diff::lines::Error`
is generic over the caller's own error and hands it back through `ProcessHunk`,
which a type alias cannot express. Each carries a `TODO(review)`.

Every message on an erased type is reproduced verbatim where it is produced.

One test changes, and it is worth saying why. `peel::to_kind::Error::NotFound`
carried the offending object id and both kinds as fields, and a revision-spec
test asserts on that frame's `Debug` inside the error tree. The text it prints
is unchanged — the assertion covering the printed form passes untouched — but
the typed frame is gone, so the tree now shows the same sentence as a
`Message`. Keeping it typed would mean holding a private concrete payload
behind the erased type; the snapshot follows the erasure instead, matching what
the migration notes prescribe for formatted variants.
`worktree::excludes`, `worktree::attributes` and `worktree::pathspec` become
`gix::Error`. All of their variants were transparent, so no message moves; the
one message in the file, on the case-sensitivity lookup in `pathspec()`, is
reproduced verbatim and still built only when that lookup fails.

`worktree::open_index` stays concrete. It is embedded by two enums that already
embed an erased error, so erasing it would give each of them a second
`From<gix::Error>` and collide: `repository::index_or_load_from_head_or_empty::Error`
through `object::peel::to_kind::Error`, and `status::is_dirty::Error` through
`status::into_iter::Error`. A `TODO(review)` names both.

That second one is worth noting for whoever continues this: `is_dirty::Error`
now pins three types concrete on its own — `reference::head_tree_id`,
`status::tree_index` and `worktree::open_index` — because one of its five
members is erased and only one can be.
`remote::find`, `remote::connection::fetch::update_refs::update` and
`object::find` become `gix::Error`. All twelve messages the first two carried are
reproduced verbatim where they are produced.

`object::find` is here rather than in its own batch because erasing
`update::Error` dissolved the reason it had been kept concrete: the collision was
with `update::Error`'s variants, and those no longer exist. Its `TODO(review)`
said otherwise and has been removed.

Five types under `remote/` stay concrete, and for two different reasons.
`remote::init` and `remote::name` would each give a parent enum a second
`From<gix::Error>`: `clone::fetch::Error` and `remote::save::AsError`
respectively. `ref_map`, `fetch` and `fetch::prepare` are blocked more firmly —
each implements `gix_protocol::transport::IsSpuriousError`, which would become a
foreign trait on a foreign type once the error is an alias, and that trait is how
a transient network failure is told apart from a permanent one. Erasing them
would cost retry behaviour, not just a trait impl. Callers also match their
variants in `env.rs` and `clone::fetch`.

`object::find` wrapped a boxed error, and `thiserror`'s derive handled that for
its two producers. `gix_error::Error::from_error` does not, so `try_find_header`
and `try_find_object` now bridge through `io::Error::other`, as this crate
already does elsewhere.
…ked others

An inventory of what remained turned up twelve types with no recorded reason for
staying concrete. Three of them were only ever mentioned as the reason *other*
types were blocked, and nobody had tried them: `status::is_dirty`,
`repository::worktree_stream` and `config::checkout_options`. None had a parent
embedding them, so all three erase.

Doing so freed three more, which are erased here too:
`reference::head_tree_id`, `config::command_context` and `config::stat_options`.
`checkout_options()` no longer needs to re-map `stat_options`' two variants by
hand, since both sides are the same type now.

Four types stay concrete, and their notes were wrong. `index_from_tree` and
`attribute_stack` blamed hubs that this commit removes; `open_index` named one
real blocker and one that no longer exists; `tree_index` had no note at all
despite being blocked by `status::iter::Error`. All four now name blockers that
were confirmed by compiling with the type erased and reading the error.

Only `worktree_stream::Error::NotATree` carried a message; it is reproduced
where it is produced. Nine call sites that wrapped these errors no longer need
to and were collapsed to `?`.
Every `thiserror` error type left in `gix` now carries a `TODO(review)`
note recording why it could not be converted to `gix-error`, so what
remains is legible rather than implicit.

Each note names one of four structural blockers found during the
conversion: callers matching variants or reading fields, a parent enum
whose single `From<gix_error::Error>` slot is already spent (E0119), a
type parameter that a `pub type` alias cannot carry, or a local
implementation of a foreign trait (E0117).

Blockers inside `gix/src` are cited by symbol name rather than by line
number, so those references survive later edits to the files they point
into. Citations into `gix/tests` keep their line numbers: they point at
caller match sites that have no unique symbol to name, in a tree this
campaign does not modify.
…ack collision

Erase three error types proven safe to collapse to `gix_error::Error`:

- `dirwalk::Error` (`gix/src/dirwalk/mod.rs`) — the hub. Its two message
  variants (`MissingWorkDir`, `ListWorktrees`) are re-raised via `or_raise`/
  `ok_or_raise` at their construction sites in `gix/src/repository/dirwalk.rs`.
- `dirwalk::iter::Error` (`gix/src/dirwalk/iter.rs`) — cfg-split on the
  `parallel` feature; repaired in both the threaded producer path and the
  serial fallback path, including its `SpawnThread` message.
- `config::exclude_stack::Error` (`gix/src/config/mod.rs`) — only possible
  now that `dirwalk::Error` no longer collides with it via two
  `From<gix_error::Error>` impls (the E0119 the surviving note used to cite).
  Both its messages (`Io`, `ExcludesFilePathInterpolation`) are re-raised at
  their construction site in `Cache::assemble_exclude_globals()`
  (`gix/src/config/cache/access.rs`).

Beyond the audited call sites, this also fixes two double-wraps that the
erasure of `exclude_stack::Error` would otherwise silently introduce — a
callee returning `gix_error::Error` wrapped again via
`.map_err(gix_error::Error::from_error)`, which compiles but nests
erased-in-erased:

- `Repository::excludes()`'s call into `assemble_exclude_globals()`
  (`gix/src/repository/attributes.rs`), collapsed to a plain `?`.
- `Worktree::excludes()`'s call into `Repository::excludes()`
  (`gix/src/worktree/mod.rs`), where the trailing `.map_err(...)` is now
  dropped entirely since both sides are `gix_error::Error`.

Deletes the `TODO(review)` note on each of the three erased types.
Erase two error types proven safe to collapse to `gix_error::Error`,
both in `gix/src/head/peel.rs`:

- `head::peel::Error` — its two variants were `#[error(transparent)]`
  (`FindExistingObject`, `PeelReference`), so no message needed re-raising;
  every internal call site (`id.header()`, `id.object()`,
  `peel_tags_to_end()`, `nr.peel_to_id()`) is now a plain
  `.map_err(gix_error::Error::from_error)`, and the one manual construction
  in `peel_to_object()` (`Error::FindExistingObject(err)`, wrapped inside
  the still-concrete `to_object::Error::Peel`) becomes
  `gix_error::Error::from_error(err)`.
- `head::peel::into_id::Error` — its `Unborn { name }` message is re-raised
  via `gix_error::Error::from_error(gix_error::message!("Branch '{name}' does
  not have any commits"))` at its construction site in `into_peeled_id()`.

Beyond the audited call sites, this also fixes two double-wraps that these
erasures would otherwise silently introduce — a callee returning
`gix_error::Error` wrapped again via `.map_err(gix_error::Error::from_error)`:
- `Repository::head_id()` (`gix/src/repository/reference.rs`): its call into
  `Head::into_peeled_id()` had a trailing `.map_err(...)` that is now dropped,
  since both sides are `gix_error::Error`.
- `Repository::modules()` (`gix/src/repository/submodule.rs`): its call into
  `Head::try_peel_to_id()` is now a plain `?`, for the same reason.

Also fixes a stale note: `reference::peel::Error`'s `TODO(review)`
(`gix/src/reference/errors.rs`) cited `head::peel::Error::PeelReference` as
a collision-free parent; that variant no longer exists once `head::peel::Error`
is a type alias, so the now-inaccurate clause is dropped. The note's actual
justification (callers match `reference::peel::Error::ToId(...)` directly)
is unaffected and unchanged.

Deletes the `TODO(review)` note on each of the two erased types.
Erase five more error types proven safe to collapse to `gix_error::Error`:

- `config::set_value::Error` (`gix/src/config/mod.rs`) — its `SetRaw`
  transparent variant becomes a plain `.map_err(gix_error::Error::from_error)`
  at the three `set_raw_value_by()` call sites in
  `gix/src/config/snapshot/access.rs`. Its `Validate` variant needs no
  wrapper: `validate::Error` is erased in this same commit, so those call
  sites remain a plain `?`. Its two message variants (`SubSectionRequired`,
  `SubSectionForbidden`) are re-raised via
  `gix_error::Error::from_error(gix_error::message(...))` at their
  construction sites in the same file.
- `config::tree::key::validate::Error` (`gix/src/config/tree/mod.rs`) — its
  sole field was `Box<dyn std::error::Error + Send + Sync + 'static>`, which
  `gix_error::Error::from_error()` can't accept directly (the std blanket
  impl needs `Sized`); bridged via `std::io::Error::other(err)` at the one
  call site in `Any::<T>::validate()` (`gix/src/config/tree/keys.rs`),
  matching the idiom already shipped in `gix/src/pathspec.rs`.
- `config::tree::key::validate_assignment::Error` (same file) — its
  `Validate` message is re-raised via `.or_raise(...)`, and its `Name`
  message via `gix_error::Error::from_error(gix_error::message!("{message}"))`,
  at both call sites in `gix/src/config/tree/traits.rs`
  (`validated_assignment()`/`validated_assignment_with_subsection()`).
- `config::snapshot::credential_helpers::Error`
  (`gix/src/config/snapshot/credential_helpers.rs`) — its `InvalidUseHttpPath`
  and `CoreAskpass` messages are re-raised via `or_raise`/`message!`, and its
  transparent `BooleanConfig` variant (plus the untouched `protect_protocol`
  and two `HELPER_STDERR`/`TERMINAL_PROMPT` sites) become plain
  `.map_err(gix_error::Error::from_error)`.
- `status::index_worktree::submodule_status::Error`
  (`gix/src/status/index_worktree.rs`) — all three transparent variants
  become plain `.map_err(gix_error::Error::from_error)` at their call sites
  in `BuiltinSubmoduleStatus::status()`.

`gix::config::tree::Key::validate()` is a public trait method; erasing
`validate::Error` changes its signature to return `gix_error::Error`, which
downstream implementors of `Key` must now satisfy. This is expected and
consistent with the `feat!` breaking-change marker used throughout this
campaign.

Also fixes a stale note: `submodule::status::Error`'s `TODO(review)`
(`gix/src/submodule/mod.rs`) cited
`status::index_worktree::submodule_status::Error::SubmoduleStatus` as its
sole collision-free `#[from]` parent; that variant no longer exists once
`submodule_status::Error` is a type alias, so the now-inaccurate clause is
dropped. The note's actual justification (callers match `Error::State(...)`
directly) is unaffected and unchanged.

Deletes the `TODO(review)` note on each of the five erased types.
Wrapping an error that is already `gix_error::Error` in
`.map_err(gix_error::Error::from_error)` nests an erased error inside
another erased error. It compiles and passes the test suite, so the
compiler cannot catch it; these were found by resolving each call site's
callee through its alias chain.

Collapse eight such sites to a plain `?`:

- `status::iter` -> `BuiltinSubmoduleStatus::new()` (`submodule::modules::Error`)
- `repository::merge` -> `merge_pipeline_options()`, `merge_drivers()` and
  `diff_algorithm()` (three sites)
- `repository::blame` -> `diff_algorithm()`
- `commit` -> `commit_graph_if_enabled()`
- `pathspec` -> `pathspec_defaults_inherit_ignore_case()`
- `filter` -> `Core::CHECK_ROUND_TRIP_ENCODING::try_into_encodings()`
  (`config::encoding::Error`)

Three of these live in `gix/src/repository/merge.rs`, which a bare
`cargo check -p gix` does not compile at all; they are only visible with
`--features merge`.

Also corrects the `TODO(review)` note on `remote::find::existing::Error`
(`gix/src/remote/errors.rs`), which claimed both its `#[from]` parents had
no other erased member. Erasing `config::snapshot::credential_helpers::Error`
spends the `From<gix_error::Error>` slot of `env::collate::fetch::Error` via
`CredentialHelperConfig`, so that type is now blocked by E0119 there in
addition to its existing caller-match blocker. The claim about the other
parent, `remote::find::for_fetch::Error`, still holds and is unchanged.
The plan had gone stale enough to mislead: it reported 33 crates pending
when only `gix` remains, listed finished crates as blocked on a refactor,
and prescribed an idiom that review had since overruled.

Record what the migration actually became — a two-tier strategy. Plumbing
crates drop `thiserror` for hand-written `Display`/`Error` impls on
concrete enums and take no `gix-error` dependency; the `gix` boundary
erases to `pub type Error = gix_error::Error;` where callers do not need
to match variants.

Add the two findings that cost the most to discover and appear nowhere in
the original plan: the four structural blockers that keep a type concrete,
and the double-wrap trap, where wrapping an already-erased error compiles
and passes the suite while nesting an error inside itself.

Note that the E0119 blocker is order-dependent rather than permanent —
erasing a hub enum deletes it and frees everything it pinned.
The doc-comment on the erased `config::transport::Error` alias explained
which of its former variants had been dropped and why — reasoning aimed
at reviewers of this change, not at callers, and it would have shipped to
docs.rs.

Move it to a line comment, leaving the doc-comment to describe only what
the type is. Every other note of this kind in the conversion already uses
a line comment.
The plan claimed every crate that dropped `thiserror` moved to
hand-written concrete errors rather than to `gix-error`. That was
generalised from the plumbing crates and is not true: 16 crates take a
`gix-error` dependency and use its types directly, `gix-date` most
completely, whose entire public error is a `ValidationError` re-export.

Those crates also had no place in the migration's stated two-tier model,
which described only the plumbing crates and the `gix` boundary. Name
them as a tier of their own so the model matches what was actually built.
Three claims in the plan did not survive checking.

The count of crates adopting `gix-error` was a dependency-key count, which
also caught `gix` itself and `gix-error`'s own dev-dependency. Fourteen
crates actually use its types in their public error surface, and two of
them were missing from the `ValidationError` list.

The taxonomy said every remaining type falls into exactly one of four
blockers. Thirteen of the forty-two cite more than one, five of them
three.

The plan also recorded nothing about the size of the change, which is the
question asked most often about it. Note the figures and that the
`TODO(review)` blocks account for much of the growth.
The note blamed the branch's line growth on the `TODO(review)` comments.
They are about 4% of it. The growth is in the plumbing crates, where
dropping `thiserror` meant writing out by hand the `Display` and `Error`
impls it used to generate.

Give the per-crate figures and drop the suggestion that moving the notes
into this file would recover the difference; it would move some 260 lines
against a total near 6,000.

State the totals approximately, since the commit that records an exact
figure is the one that invalidates it.
@Byron

Copy link
Copy Markdown
Member

Thanks for giving it a shot!

I am closing it in favor of #2847 as it already shows diff-lines that are much closer to what I'd expect, so I will be iterating there, soon hopefully to get rid of these huge PRs.

Thanks for your understanding.

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