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:
A scaffolder that never fetches a release wants:
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.