Skip to content

Repo operations

Repo is the async git handle in rtb_forge::git, available when the git Cargo feature is on (it is by default). It wraps a gix::ThreadSafeRepository plus the path it was opened from.

use rtb_forge::git::Repo;

let repo = Repo::open(".").await?;

Repo is Debug + Clone + Send + Sync. Cloning is cheap — the gix handle is internally shared — so handles fan out across tokio::spawn boundaries without ceremony.

Every method that does blocking work runs it inside tokio::task::spawn_blocking, so calling one does not stall the runtime. A spawn_blocking join failure is reported as that method's own failure variant with a spawn_blocking join cause rather than panicking.

Which methods need the git binary on PATH?

This matters for containers and for cross-compiled binaries.

Method Backend
open gix
init gix
clone without a credential gix
walk gix
diff gix
blame gix (gix-blame)
status gix
clone with a credential git subprocess
fetch git subprocess
checkout git subprocess
commit git subprocess (add, commit, rev-parse)
push git subprocess

Read paths are pure Rust. Write paths are not. A build without git available at runtime can open, walk, diff, blame and status a repository, and can clone anonymously — but commit, checkout, fetch, push and authenticated clone will fail with a spawn error in the cause field.

libgit2 is not used anywhere. See Why the write paths shell out to git.

Repo::open

pub async fn open(path: impl AsRef<Path>) -> Result<Self, RepoError>

Opens an existing repository. Discovery follows git's own rules, so a path inside a working tree finds the enclosing .git.

Fails with RepoError::OpenFailed { path, cause } when the path holds no repository or gix cannot read it.

path() returns what you passed in, not the worktree root. The subprocess methods (commit, checkout, fetch, push) run git with that directory as the working directory. Opening a subdirectory and then committing a worktree-relative path will not resolve the way you expect — pass the worktree root to open if you intend to write.

Repo::init

pub async fn init(path: impl AsRef<Path>, _opts: InitOptions) -> Result<Self, RepoError>

Creates a repository at path, creating the directory if it does not exist — the same semantics as git init <path>. Returns a handle to the new repository.

InitOptions is an empty #[non_exhaustive] struct today. There is no way to request a bare repository, set the initial branch name, or apply a template; those knobs are deliberately absent until a consumer needs them. Pass InitOptions::default().

The initial branch is whatever gix defaults to for the environment, which respects init.defaultBranch from git config.

Fails with RepoError::InitFailed { path, cause }.

Repo::clone

pub async fn clone(url: &str, dst: impl AsRef<Path>, opts: CloneOptions)
    -> Result<Self, RepoError>

dst must not exist, or must be an empty directory.

CloneOptions has one field:

Field Type Default Effect
credential Option<CredentialRef> None Set it and the clone authenticates over HTTPS

Build it with the with_credential builder:

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

The two paths behave differently, and the difference is observable:

  • No credentialgix::prepare_clonefetch_then_checkoutmain_worktree. Progress is discarded. file:// URLs work, which is what makes offline testing possible.
  • With a credential — the token is resolved first, then git clone runs with -c credential.helper=... and the secret in the subprocess environment.

There is no shallow-clone option, no branch or depth selection, and no progress reporting on either path.

Fails with RepoError::CloneFailed { url, cause }, or RepoError::Auth when the credential could not be resolved.

Repo::walk

pub fn walk(&self, revspec: &str) -> Result<CommitWalk, RepoError>

Not async. It resolves the revspec synchronously and returns immediately; the traversal itself runs on a background blocking task.

CommitWalk implements futures_core::Stream<Item = Result<CommitInfo, RepoError>>. Consume it with futures::StreamExt:

use futures::StreamExt as _;

let mut walk = repo.walk("HEAD")?;
while let Some(commit) = walk.next().await {
    let commit = commit?;
    println!("{} {}", &commit.id[..7], commit.summary);
}

Supported revspec kinds:

Form Example Meaning
Include HEAD, v1.2.0 Everything reachable from one tip
Range v1.2.0..HEAD Reachable from HEAD, excluding v1.2.0
Merge main...topic Reachable from either tip

Anything else — HEAD^!, ^HEAD, and the other gix revision kinds — is rejected with RepoError::WalkFailed { cause: "unsupported revspec kind: …" }.

CommitInfo carries owned data only, so it survives being moved across tasks:

Field Notes
id Full hex OID
summary First line of the message
message Title, then a blank line, then the body — reassembled, so it may not be byte-identical to the raw commit message
author_name, author_email From the author, not the committer
time_seconds Author timestamp, Unix seconds. 0 when it will not parse

The producer pushes into a bounded channel of 64 entries, so a slow consumer applies backpressure rather than buffering the whole history. Dropping the stream drops the receiver and the producer abandons the walk on its next send.

Errors behave in two tiers: a revspec that does not resolve fails immediately with RepoError::RevspecNotFound; a failure part-way through the traversal arrives in-band as an Err item on the stream, and the stream then ends.

Repo::diff

pub async fn diff(&self, a: &str, b: &str) -> Result<Diff, RepoError>

Diffs the trees of two commits. Both a and b must resolve to a single commit — HEAD~2, a tag, a full or abbreviated OID. A range revspec is not accepted here; pass the two ends separately.

Diff { changes: Vec<FileChange> }, where FileChange { path, kind } and kind is one of:

ChangeKind Meaning
Added Present in b, absent in a
Modified Present in both with different content or mode
Deleted Present in a, absent in b
Renamed { from } gix's rewrite tracker paired a deletion with an addition. path is the destination, from the source

Ordering is whatever gix emits — not alphabetical, and not stable enough to assert on. Sort by path if you need determinism.

This is file granularity only. There are no hunks, no line counts, and no patch text. Diff is #[non_exhaustive], so hunk data can be added later without a breaking change.

Fails with RepoError::RevspecNotFound { revspec } naming whichever side did not resolve, or RepoError::DiffFailed { cause }.

Repo::blame

pub async fn blame(&self, path: &Path, revspec: &str) -> Result<Blame, RepoError>

Per-line authorship for one file as it existed at revspec.

path must be repository-relative. It is handed to gix verbatim; an absolute path or one containing ./ will not match a tree entry and comes back as RepoError::RevspecNotFound with a revspec field of the form <file> at <revspec>.

Blame { file, lines: Vec<BlameLine> }. file echoes the path you passed. Each BlameLine carries line_number (1-indexed), content (no trailing newline), commit_id, author_name, author_email and time_seconds.

gix produces hunks; this method flattens them to one entry per line and sorts by line number, matching git blame --porcelain semantics. Author details are denormalised onto every line, with a per-commit cache so a hunk-heavy file does not re-read the same commit object repeatedly.

There is no way to blame a line range, to follow renames explicitly, or to ignore revisions — gix::blame::Options::default() is used unconditionally.

Non-revspec failures surface as RepoError::WalkFailed, not a blame-specific variant.

Repo::status

pub async fn status(&self) -> Result<RepoStatus, RepoError>

RepoStatus has three Vec<PathBuf> buckets: staged, unstaged, untracked.

RepoStatus::staged is always empty

The implementation does not configure a HEAD tree on the gix status platform, so the tree-to-index half of the comparison never runs and staged is constructed as an empty vector on every call. The API documentation describes it as an index-versus-HEAD diff; the code does not compute one.

A staged new file is invisible in all three buckets — it is not staged because that bucket is never filled, and it is not untracked because the index knows about it:

$ git add STAGED.md          # then call repo.status()
staged=[] unstaged=["CHANGELOG.md"] untracked=["SCRATCH.md"]

This is load-bearing elsewhere: Repo::checkout's dirty-tree guard inspects both staged and unstaged, so staged-but-uncommitted changes do not trip the guard. A non-forced checkout will proceed over them and git checkout decides what happens next.

Do not use status() to answer "is there anything to commit". Use it for untracked and unstaged detection only.

untracked reports files, not directories — the status walk is configured with UntrackedFiles::Files. Ignored, pruned and tracked directory entries are not surfaced. Rewrites (rename detection in the worktree) are reported in unstaged under their destination path.

Fails with RepoError::StatusFailed { cause }.

Repo::commit

pub async fn commit(&self, paths: &[&Path], message: &str) -> Result<String, RepoError>

Stages paths and commits them. Returns the new commit's OID as a 40-character hex string.

Runs three subprocesses in the repository directory: git add -- <paths…>, git commit -m <message>, git rev-parse HEAD. The -- guards paths that begin with -.

  • paths must be non-empty. An empty slice fails immediately with RepoError::CommitFailed { cause: "no paths supplied — commit requires at least one path to stage" } without running anything.
  • Paths that exist on disk are staged as additions or modifications; paths that no longer exist are staged as deletions. This is git add semantics.
  • --allow-empty is not passed. Committing when nothing is staged fails, the same as plain git commit.
  • There is no way to set the author or committer through this API. Identity comes from git config the way git itself resolves it (worktree, global, system, environment). Configure it before calling — a repository with no user.email produces RepoError::CommitFailed carrying git's own message.
  • There is no signing option, no amend, and no way to supply a multi-paragraph message other than embedding newlines in message.

Repo::fetch

pub async fn fetch(&self, remote: &str, opts: FetchOptions) -> Result<(), RepoError>

Runs git fetch <remote>. Updates remote-tracking refs; does not touch the working tree or local branches.

FetchOptions has one field, credential: Option<CredentialRef>, set via with_credential. There is no refspec, prune, tags or depth option — the subprocess is exactly git fetch <remote>.

GIT_TERMINAL_PROMPT=0 is set, so a credential failure returns non-zero immediately instead of blocking on a prompt.

Fails with RepoError::FetchFailed { remote, cause } (the cause is git's stderr, trimmed), or RepoError::Auth.

Repo::checkout

pub async fn checkout(&self, revspec: &str, opts: CheckoutOptions) -> Result<(), RepoError>

Switches the working tree to revspec by running git checkout <revspec>.

CheckoutOptions has one field:

Field Type Default Effect
force bool false Skips the dirty-tree guard and passes --force to git

Construct it with CheckoutOptions::default() or CheckoutOptions::forced(). There is no force(bool) builder method despite what the API docs on Repo::checkout say, and the struct is #[non_exhaustive], so CheckoutOptions { force: true } will not compile from outside the crate.

With force = false, status() runs first and the call is refused with RepoError::DirtyWorkingTree { paths } if anything is unstaged. Because staged is always empty (above), staged changes do not trip this guard.

git's own stderr is inspected to classify the failure. If it contains did not match any, unknown revision, not a valid object name, or pathspec, the result is RepoError::RevspecNotFound { revspec }; anything else is RepoError::CheckoutFailed { revspec, cause }. That is text matching against a localised program: a git running under a non-English locale may classify a missing revspec as CheckoutFailed.

Repo::push

pub async fn push(&self, remote: &str, refspec: &str, opts: PushOptions)
    -> Result<(), RepoError>

Runs git push <remote> <refspec>. PushOptions carries an optional credential, set via with_credential.

There is no force option, no lease, no atomic flag and no tags flag. Encode what you need in the refspec — +refs/heads/main:refs/heads/main forces, HEAD:refs/heads/topic pushes the current head to a new branch.

GIT_TERMINAL_PROMPT=0 is set.

Fails with RepoError::PushFailed { remote, refspec, cause } — unknown remote, rejected non-fast-forward, network failure, all of them. RepoError::Auth when the credential could not be resolved.

RepoError::PushUnsupported is never produced, whatever its help text says.

Repo::path

pub fn path(&self) -> &Path

The path the handle was constructed from — the argument to open, init, or the dst of a clone. It is stable for the life of the handle, and it is the working directory every subprocess method uses. It is not normalised to the worktree root or to the .git directory.