Skip to content

Authenticate clone, fetch and push

Three Repo operations can authenticate: clone, fetch and push. Each takes an options struct with an optional CredentialRef.

Attach a credential

use rtb_credentials::CredentialRef;
use rtb_forge::git::{CloneOptions, Repo};

let cref = CredentialRef {
    env: Some("GITLAB_TOKEN".into()),
    ..Default::default()
};

let repo = Repo::clone(
    "https://gitlab.com/phpboyscout/rust/forge.git",
    "/tmp/forge",
    CloneOptions::default().with_credential(cref),
).await?;

FetchOptions and PushOptions work identically:

use rtb_forge::git::{FetchOptions, PushOptions};

repo.fetch("origin", FetchOptions::default().with_credential(cref.clone())).await?;
repo.push("origin", "HEAD:refs/heads/topic", PushOptions::default().with_credential(cref)).await?;

Omit with_credential and the operation runs anonymously.

Choose where the secret comes from

CredentialRef is rtb-credentials' declarative reference. It is resolved through a fixed precedence chain — environment variable, then OS keychain, then a literal in config, then a fallback environment variable — and the first one that produces a value wins.

use rtb_credentials::{CredentialRef, KeychainRef};

// From an environment variable.
CredentialRef { env: Some("GITLAB_TOKEN".into()), ..Default::default() }

// From the OS keychain.
CredentialRef {
    keychain: Some(KeychainRef { service: "mytool".into(), account: "gitlab".into() }),
    ..Default::default()
}

// Environment variable, falling back to an ecosystem default.
CredentialRef {
    env: Some("MYTOOL_GITLAB_TOKEN".into()),
    fallback_env: Some("GITLAB_TOKEN".into()),
    ..Default::default()
}

CredentialRef derives Deserialize, so the usual arrangement is to carry it in your tool's own config and let the user decide:

forge:
  credential:
    env: GITLAB_TOKEN

A literal secret in config is refused when the process runs with CI=true. That is rtb-credentials' rule, not this crate's.

Understand where the secret ends up

rtb-forge resolves the credential to a SecretString, then runs git with:

  • the secret in the child process's environment as RTB_VCS_GIT_TOKEN;
  • -c credential.helper='!f() { echo username=x-access-token; echo password=$RTB_VCS_GIT_TOKEN; }; f' in argv — a script that contains no secret, only the variable name;
  • GIT_TERMINAL_PROMPT=0, so an auth failure exits rather than prompting.

The secret is never in argv, never in a temporary file, and never written to ~/.git-credentials or any repository config. It is not logged: error cause strings carry git's stderr, which does not include the token.

The variable name still says VCS because the crate was renamed from rtb-vcs; the name is part of the runtime contract and was deliberately not changed. There is no RTB_FORGE_GIT_TOKEN.

Know the constraints before you debug

HTTPS only. The credential helper mechanism applies to HTTPS remotes. An ssh:// or git@host: remote authenticates through the SSH agent as usual and ignores the CredentialRef entirely.

The username is fixed. It is always x-access-token — GitHub's PAT convention, which GitLab and Gitea also accept. There is no way to change it. A forge requiring a real username alongside a token cannot be authenticated through these methods; run git yourself for that case.

Authenticated clone needs the git binary. An anonymous clone runs on gix and needs nothing external. Adding a credential switches it to a git subprocess. On a machine without git on PATH, the anonymous clone succeeds and the authenticated one fails — see Why the write paths shell out to git.

Tell the two failure modes apart

They are different variants and they mean different things:

use rtb_forge::git::RepoError;

match repo.fetch("origin", opts).await {
    Err(RepoError::Auth(source)) => {
        // The credential could not be RESOLVED locally.
        // Env var unset, keychain locked, literal refused under CI=true.
        eprintln!("credential problem: {source}");
    }
    Err(RepoError::FetchFailed { remote, cause }) => {
        // The credential was resolved and the REMOTE rejected it,
        // or the network failed. `cause` is git's stderr.
        eprintln!("fetch from {remote} failed: {cause}");
    }
    Err(other) => return Err(other.into()),
    Ok(()) => {}
}

RepoError::Auth never means "the server said no". If you see it, check the environment variable name and the keychain entry, not the token's scopes.

Reuse one credential across operations

CredentialRef is Clone, and resolution happens per call — the secret is not cached between operations. Clone the reference, not the secret:

let cref = CredentialRef { env: Some("GITLAB_TOKEN".into()), ..Default::default() };

let repo = Repo::clone(url, dst, CloneOptions::default().with_credential(cref.clone())).await?;
repo.fetch("origin", FetchOptions::default().with_credential(cref.clone())).await?;
repo.push("origin", "main", PushOptions::default().with_credential(cref)).await?;

Each call re-walks the precedence chain, so a token rotated in the keychain between calls takes effect immediately.