What rtb-forge does not do¶
Stated as limits, so you can rule things out without reading the source.
The release slice cannot write anything¶
ReleaseProvider has four methods and all four are reads. There is no
create-release, no upload-asset, no edit-tag, no delete. This is a scope
decision, not an omission: the slice exists so a tool can discover and download
its own newer build, and write access to a forge is a different security
posture entirely.
If you need to publish releases, use the forge's own CLI or API client. Nothing in this crate will grow that capability.
Nothing is cached¶
Every call hits the network. Two consecutive latest_release() calls make two
requests; there is no ETag handling, no conditional request, no in-memory
memoisation, and no on-disk cache.
For a self-updating CLI that checks once per invocation this is correct. For
anything polling on a timer it is not, and the intended fix is to add HTTP
caching middleware to the reqwest client — which this crate does not expose.
You cannot supply your own reqwest::Client; each factory builds its own.
Release listings do not paginate¶
list_releases(limit) fetches exactly one page. The limit is clamped to the
backend's page cap — 100 on GitHub and GitLab, 50 on Gitea and Codeberg — and
no Link header or next cursor is followed. Asking for 500 releases returns
at most 100.
There is no way to reach older releases beyond the first page. If you need full history, use the forge's API directly.
Bitbucket cannot list releases at all¶
list_releases on the Bitbucket backend returns ProviderError::Unsupported
unconditionally. Bitbucket Cloud has no releases concept; the backend
synthesises single releases by pairing a git tag with repository Downloads
whose filenames contain the tag string. Doing that for a listing would mean a
downloads lookup per tag, and the filename matching is a heuristic, not a
guarantee.
Bitbucket Data Center / Server is not supported at all. Its URL shape
(/rest/api/1.0) and response JSON both differ from Cloud, and only Cloud is
implemented. A Data Center host will produce parse failures, not a clear error.
Bitbucket OAuth 2.0 is also unsupported; App Passwords over HTTP Basic are the only authentication path.
You cannot mix a per-backend host with Codeberg¶
source_type: codeberg has no host key — the host is a compile-time constant
pinned to codeberg.org. There is no configuration that points the Codeberg
backend at another instance. Use source_type: gitea with an explicit host
instead; it is the same code path.
Likewise, enabling the codeberg Cargo feature always enables gitea. There
is no build that has one without the other.
The private configuration key does nothing¶
Every forge parameter struct has a private: bool, documented as "true when
auth is required even for read operations". No backend reads it. Requests are
authenticated when a token is supplied to the factory and anonymous when it is
not, regardless of this key.
Setting private: true will not cause an early failure when no credential is
available, and setting it to false will not suppress an Authorization
header. Treat it as reserved.
Bitbucket authentication fails open, not closed¶
The Bitbucket backend only sends credentials when username and a token
are both present. Supply a token but no username and the request is sent
anonymously with no warning — the failure arrives later as a 404 from
Bitbucket, indistinguishable from a genuinely missing repository.
HTTP is not configurable, and neither is HTTPS enforcement¶
Every backend builds its own reqwest::Client with https_only on. You cannot
inject a client, add middleware, set a proxy programmatically, disable
certificate verification, or add a custom root CA. The
allow_insecure_base_url field on every params struct exists for the crate's
own tests, is #[serde(skip)], and cannot be set from any config file.
timeout_seconds is the only HTTP knob, and setting it to 0 removes the
timeout rather than making it short. There is no separate connect timeout and
no retry policy — a RateLimited error tells you what the host asked for, and
backing off is your job.
RepoStatus::staged is always empty¶
Repo::status() returns three buckets and populates two of them. The
tree-to-index comparison is never configured, so staged is constructed empty
on every call regardless of what is in the index. A newly added file that has
been git add-ed appears in none of the three buckets: not in staged,
because that bucket is never filled, and not in untracked, because the index
knows about it.
The knock-on effect is in Repo::checkout: its dirty-tree guard inspects
staged and unstaged, so staged-but-uncommitted changes do not block a
non-forced checkout. Do not use status() to decide whether there is
anything to commit.
The git surface is a foundation, not a full client¶
Repo covers eleven operations and stops. There is no branch creation, no
merge, no rebase, no cherry-pick, no stash, no tag creation, no remote
management, no submodule handling, no reflog, no config access, and no
worktree management.
Within the operations that do exist, the option sets are minimal by design:
InitOptionsis empty. No bare repositories, no initial-branch override, no templates.CloneOptionscarries only a credential. No depth, no branch, no single-branch, no progress reporting.FetchOptionscarries only a credential. No refspec, no prune, no tags.PushOptionscarries only a credential. No force flag, no lease, no atomic — encode what you need in the refspec.CheckoutOptionscarries onlyforce.Repo::commithas no author override, no amend, no signing, no--allow-empty.Repo::diffis file-granularity only. No hunks, no line counts, no patch text.Repo::blamehas no line range, no ignore-revisions, no rename-following control.
Each is absent because no consumer has needed it, and each options struct is
#[non_exhaustive] so adding one later is not a breaking change.
Repo::walk supports three revspec forms¶
HEAD-style include, A..B range, and A...B merge. Every other gix revision
kind — HEAD^!, ^HEAD, and the rest — is rejected with
RepoError::WalkFailed { cause: "unsupported revspec kind: …" }.
The direct backend cannot verify anything¶
With no pinned_version configured, release_by_tag makes no request at all
and synthesises a release for whatever string you pass. Ask for v99.0.0 and
you get a Release tagged v99.0.0 whose asset URL 404s at download time.
There is no listing endpoint to check against, and none is synthesised.
Its created_at is always the Unix epoch, its body is always empty, and its
asset size is always 0. Chronological ordering across direct sources is not
meaningful.
{target} silently vanishes on unusual platforms¶
The direct backend's {target} placeholder is resolved from a table of six
Rust triples: x86_64 and aarch64 on Linux, macOS and Windows. Anything else —
32-bit ARM, musl targets addressed as such, FreeBSD — substitutes the empty
string rather than raising an error or leaving the placeholder in place. The
rendered URL looks plausible and 404s.
Build the URL from {os} and {arch} if you ship outside that table.
GitLab metadata is partly inferred¶
GitLab's release API has no draft flag and no prerelease flag, so the backend
fills them by other means: draft is always false, and prerelease is set
when the tag string contains a hyphen. That is right for 1.2.3-rc.1 and wrong
for a date-shaped tag like 2026-04-23, which will be reported as a
prerelease.
GitLab release assets are links rather than uploaded blobs, so their size is
always 0 and content_type carries GitLab's link_type (package, image,
runbook, other) rather than a MIME type.
GitLab's latest_release can report "not found" on a repository with releases¶
It requests a single release and filters out upcoming (scheduled) ones. If the
newest release is upcoming, the filtered list is empty and the call returns
ProviderError::NotFound { what: "latest release" } even though published
releases exist. Use list_releases and filter yourself if you schedule
releases ahead.
A plain 403 is reported as a transport error¶
The shared status mapping recognises 401, 404, 429, and GitHub's
rate-limit 403. Everything else — a plain 403 from an under-scoped token,
a 500, a 502 from a proxy — becomes
ProviderError::Transport("unexpected status … from …"). There is no
server-error variant and no permission-denied variant distinct from 401.
Known documentation-versus-code discrepancies¶
These are defects in the crate's own API documentation, listed here so that finding one does not send you looking for behaviour that is not there:
| Claim | Where | Reality |
|---|---|---|
"Not Git. Repository operations … land as rtb-forge v0.2" |
crate-level rustdoc | The git module has shipped and is default-on |
"git2 is an opt-in fallback … Gated on the git2-fallback Cargo feature" |
git module rustdoc |
No such feature is declared; cargo rejects it |
"PushFailed … only reachable when the git2-fallback Cargo feature is enabled" |
RepoError::PushFailed rustdoc |
It is the ordinary failure variant for Repo::push |
"push is not supported without the git2-fallback Cargo feature" |
RepoError::PushUnsupported message |
Nothing constructs this variant; push works |
"Pass CheckoutOptions::force(true) to override" |
Repo::checkout rustdoc |
The constructor is CheckoutOptions::forced() |
"CheckoutOptions { force: true }" |
checkout module rustdoc |
The struct is #[non_exhaustive]; that literal does not compile downstream |
"staged — paths that are staged for the next commit (index vs HEAD tree diff)" |
RepoStatus rustdoc |
Always empty; the diff is never computed |
"unknown combinations produce an empty target string, which surfaces as {target} staying literal" |
direct::host_substitutions rustdoc |
{target} is replaced by the empty string, not left in place |
"true when auth is required even for read operations" |
every private field |
Never read by any backend |
The code is authoritative in every row.