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.
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¶
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¶
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¶
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 credential —
gix::prepare_clone→fetch_then_checkout→main_worktree. Progress is discarded.file://URLs work, which is what makes offline testing possible. - With a credential — the token is resolved first, then
git cloneruns 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¶
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¶
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¶
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¶
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¶
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 -.
pathsmust be non-empty. An empty slice fails immediately withRepoError::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 addsemantics. --allow-emptyis not passed. Committing when nothing is staged fails, the same as plaingit commit.- There is no way to set the author or committer through this API. Identity
comes from git config the way
gititself resolves it (worktree, global, system, environment). Configure it before calling — a repository with nouser.emailproducesRepoError::CommitFailedcarrying 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¶
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¶
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¶
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¶
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.