Skip to content

rtb-forge

Release-source APIs and git operations for Rust CLI tools, in two feature-gated slices: the ReleaseProvider trait with six built-in backends, and the Repo async git wrapper built on gix.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.

use rtb_forge::git::Repo;

let repo = Repo::open(".").await?;
let status = repo.status().await?;
println!("{} untracked files", status.untracked.len());

Where do I start?

If you want to Go to
Get something working from nothing Tutorial: read a repository's history
Solve one specific task How-to guides
Look up a key, flag, method or error Reference
Understand why it is built this way Explanation
Know what it will not do What rtb-forge does not do
Read the generated API docs docs.rs/rtb-forge

What the two slices are

Slice Feature gate Default Typical consumers
Release providers per-backend (github, gitlab, …) on rtb-update self-update, release-notes tools
Git ops (Repo) git on scaffolders, release tools, generic git-aware CLIs

They share no types, and each can be compiled without the other. See why they share one crate.

Release providers

A read-only abstraction over "where do this tool's binaries live". Six backends — GitHub, GitLab, Gitea, Codeberg, Bitbucket, and a plain-HTTPS Direct source — implement four methods:

#[async_trait::async_trait]
pub trait ReleaseProvider: Send + Sync + 'static {
    async fn latest_release(&self) -> Result<Release, ProviderError>;
    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError>;
    async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError>;
    async fn download_asset(&self, asset: &ReleaseAsset)
        -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError>;
}

Backends register at link time via linkme::distributed_slice on RELEASE_PROVIDERS — there is no init(). Resolve one with rtb_forge::lookup(source_type), and list what this binary supports with rtb_forge::registered_types().

Nothing here writes: creating releases, uploading assets and editing tags are deliberately out of scope. Nothing here caches, either — every call hits the wire.

Git operations (Repo)

The async git wrapper, gated on the git Cargo feature (default-on).

impl Repo {
    pub async fn init(path, InitOptions) -> Result<Self, RepoError>;
    pub async fn open(path) -> Result<Self, RepoError>;
    pub async fn clone(url, dst, CloneOptions) -> Result<Self, RepoError>;
    pub fn walk(&self, revspec) -> Result<CommitWalk, RepoError>;
    pub async fn diff(&self, a, b) -> Result<Diff, RepoError>;
    pub async fn blame(&self, path, revspec) -> Result<Blame, RepoError>;
    pub async fn status(&self) -> Result<RepoStatus, RepoError>;
    pub async fn commit(&self, paths, message) -> Result<String /* OID */, RepoError>;
    pub async fn fetch(&self, remote, FetchOptions) -> Result<(), RepoError>;
    pub async fn checkout(&self, revspec, CheckoutOptions) -> Result<(), RepoError>;
    pub async fn push(&self, remote, refspec, PushOptions) -> Result<(), RepoError>;
    pub fn path(&self) -> &Path;
}

Repo is Send + Sync + Clone and cheap to clone — the underlying gix handle is a gix::ThreadSafeRepository — so handles fan out across tokio::spawn boundaries. Every blocking call runs inside tokio::task::spawn_blocking.

Each method is covered in Repo operations, including its options struct, its failure variants and which backend performs it.

Does this need the git binary at runtime?

For read operations, no. For write operations, yes.

open, init, walk, diff, blame, status and anonymous clone run on gix in pure Rust. commit, checkout, fetch, push and authenticated clone shell out to the git binary, which therefore has to be on PATH wherever your binary runs. libgit2 is not used anywhere, under any feature combination.

This is the most common surprise in the crate, and why the write paths shell out to git explains the reasoning per operation.

How does authentication work?

clone, fetch and push take an optional CredentialRef on their options struct. When set, the credential is resolved through rtb_credentials::Resolver::with_platform_default() — environment variable, then OS keychain, then a config literal, then a fallback environment variable — and the resulting secret reaches git through:

  • RTB_VCS_GIT_TOKEN=<secret> in the subprocess environment. The name still says VCS because the crate was renamed from rtb-vcs; it is part of the runtime contract and was deliberately left alone.
  • an inline -c credential.helper snippet that reads that variable. The snippet placed in argv contains no secret.
  • GIT_TERMINAL_PROMPT=0, so an auth failure exits instead of prompting.

The username is hard-coded to x-access-token. Recipes and failure modes in Authenticate clone, fetch and push.

Cargo features

default = ["github", "gitlab", "gitea", "codeberg", "direct", "bitbucket", "git"]

Trim to what you use:

# A self-updater that only talks to GitHub.
rtb-forge = { version = "0.7", default-features = false, features = ["github"] }

# A scaffolder that only needs git.
rtb-forge = { version = "0.7", default-features = false, features = ["git"] }

Full table — including the integration test feature, and the git2-fallback feature that does not exist despite being referenced in the crate's API docs — in Cargo features.

Formerly rtb-vcs

Renamed at 0.7.0, at extraction from the rust-tool-base monorepo, for name convergence with the GTB family (the Go counterpart lives at forge.go.phpboyscout.uk). Only the crate identity changed: every type, module and Cargo feature is exactly as it was in rtb-vcs 0.7.0, including the RTB_VCS_GIT_TOKEN environment variable.

Migrating is a rename: change the dependency name and the rtb_vcs:: import prefix. Nothing else moves.

Consumers

Crate Uses
rtb-update ReleaseProvider + backends for self-update
rtb-cli-bin scaffolder Repo::init + Repo::commit for rtb new; Repo::diff for rtb regenerate
Downstream operator-flow tools Repo as the git foundation

How this crate is tested

  • Unit suites under tests/ covering lifecycle, read paths, blame, write paths, fetch/checkout, auth, and push. Fixtures shell out to the host git CLI to build multi-commit and bare-repo fixtures in temporary directories.
  • BDD scenarios (cucumber) in tests/features/.
  • Release-provider backend tests (wiremock) under tests/*_backend.rs, plus testcontainers-backed Gitea integration tests behind the integration feature. The Gitea containers are serialised via .config/nextest.toml because racing them exhausts testcontainers' startup timeout on shared runners.

just ci runs the full local gate: fmt-check, lint, docs, test, audit.

Further reading

The blog carries a curated route through this subject: Rust, and what survived the port collects everything written about it, ordered so you can start at the beginning rather than newest-first.

Ask phpbotscout

phpbotscout

He answers questions about the projects over on the Discord, citing the docs where they already cover it, and offering to raise an issue where they don't. Bring a bug, an idea, or a questionable engineering decision.

Join the Discord