rtb-forge 0.8.1

Release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct) plus an async git-operations Repo built on gix. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Authenticate clone, fetch and push
description: How to attach a credential to a git operation, where the secret goes, and what to check when authentication fails.
tags: [how-to, git, credentials, security]
---

# 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

```rust
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:

```rust
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.

```rust
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:

```yaml
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](../explanation/03-why-write-paths-shell-out.md).

## Tell the two failure modes apart

They are different variants and they mean different things:

```rust
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:

```rust
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.