Skip to content

Why release providers and git operations share one crate

Open rtb-forge expecting a git library and you find an HTTP client for six forge APIs. Open it expecting a release-fetching library and you find a commit walker. Both are there on purpose.

The two slices

The release-provider slice is a read-only abstraction over "where do this tool's binaries live". ReleaseProvider has four methods — latest release, release by tag, list releases, download an asset — and six backends implement it: GitHub, GitLab, Gitea, Codeberg, Bitbucket, and a plain-HTTPS "direct" source. Its consumer is self-update: a CLI that needs to discover whether a newer build of itself exists and stream it down.

The git-operations slice is the Repo type: an async wrapper that can init, open, clone, walk, diff, blame, status, commit, fetch, checkout and push. Its consumers are scaffolders and release tooling — code that generates a project and commits it, or that reads a commit range to write release notes.

They share no types. Release never meets Repo.

Why they are one crate and not two

Because they are the same question asked at two levels: how does a Rust CLI tool talk to the place its code lives? A tool that self-updates from GitHub releases is usually the same tool that clones a template repository and commits into it. Splitting them would mean two crates with the same consumers, the same release cadence, and the same six-forge domain knowledge duplicated across both.

The name is the giveaway. The crate is named after the forge — the hosting platform — not after either capability.

There is also a naming history worth knowing: this crate was rtb-vcs until 0.7.0, when it was extracted from the rust-tool-base monorepo and renamed for convergence with the Go toolkit's forge module. Nothing but the crate name changed at that point — types, modules, Cargo features and the RTB_VCS_GIT_TOKEN environment variable all carried over unchanged, which is why that variable still has a VCS in it.

What the split costs you, and how to avoid paying it

Compiling both slices means compiling both dependency trees: reqwest and its TLS stack for the release backends, gix for the git operations. That is the main cost of the arrangement, and it is why every backend and the entire git module are behind Cargo features.

A self-updating CLI that never touches a working tree wants:

rtb-forge = { version = "0.7", default-features = false, features = ["github"] }

A scaffolder that never fetches a release wants:

rtb-forge = { version = "0.7", default-features = false, features = ["git"] }

Neither of those builds compiles the other slice at all. The full feature list is in Cargo features.

Why both slices are async but built on blocking code

gix is a blocking library. git is a subprocess. Neither is async, and wrapping them in a futures shim would be pretending.

Instead every Repo method that does real work moves it onto tokio::task::spawn_blocking and awaits the handle. The caller sees an async fn; the blocking work happens on the blocking pool where it belongs and does not stall the reactor.

That has a consequence that shows up in the API: Repo::walk is not async. It resolves the revspec on the calling thread — cheap — and returns a CommitWalk stream whose producer is a blocking task piping through a bounded channel of 64 commits. A long history is never materialised as a Vec, and a slow consumer applies backpressure to the walk instead of buffering it.

The release slice needs none of this: reqwest is natively async, so the four trait methods are ordinary async fns made object-safe with async-trait.

Why the error types are wrapped, not passed through

RepoError never exposes a gix::Error, and ProviderError never exposes a reqwest::Error. Both carry semantic variants with a stringified cause.

This is deliberate insulation. The backend choice for any given operation is an internal decision — anonymous clone uses gix, authenticated clone uses the git binary — and that choice is expected to change. If gix::Error were in the public signature, swapping backends would be a breaking change, and every downstream tool would have a match on a foreign error type that stopped compiling.

The practical rule for callers: match on the variant, never on the cause string. matches!(err, RepoError::CloneFailed { .. }) is stable; err.to_string().contains("refusing to merge") is not.

One exception is deliberate. RepoError::Auth wraps rtb_credentials::CredentialError directly rather than stringifying it, because rtb-credentials is part of the same toolkit's stable surface — it is not a backend that might be swapped out.