Why the write paths shell out to git¶
rtb-forge is described — including in its own README and in a phpboyscout
blog post — as being built on gix, the pure-Rust Git implementation. That is
half true, and the other half determines whether your binary works in a
scratch container.
What actually runs where¶
| Operation | Implementation |
|---|---|
open, init |
gix |
walk, diff, blame, status |
gix |
clone without a credential |
gix |
clone with a credential |
git subprocess |
commit |
git add, git commit, git rev-parse |
checkout |
git checkout |
fetch |
git fetch |
push |
git push |
Read paths are pure Rust. Write paths are not. A binary using
Repo::commit, checkout, fetch, push, or an authenticated clone
requires the git binary on PATH at runtime.
libgit2 is not involved in any of this. Older design notes reserved a
git2-fallback Cargo feature for operations gix could not do; it was never
declared, and the leftovers — the RepoError::PushUnsupported variant, and the
API note on PushFailed claiming it needs that feature — are stale. See
Cargo features.
Why not pure gix throughout¶
Three separate reasons, one per group of operations.
Commit. gix has no high-level "stage these paths and commit" helper.
Building one means loading the index (or synthesising an empty one when there
is no HEAD), writing blobs, updating index entries, writing a tree, resolving
author and committer identity from the config cascade, then writing the commit
and updating the ref. That is roughly fifty lines of plumbing that duplicates
what git add && git commit already does correctly, including hooks and
core.autocrlf and every other behaviour a user expects.
Authenticated network operations. gix's credential story does not
compose cleanly with an external credential resolver. git already has a
credential-helper protocol designed exactly for "some other process knows the
secret", and it is a well-understood, auditable interface. Shelling out means
the secret goes into the subprocess environment and is read by an inline helper
— never argv, never a file. Reimplementing that inside gix would be new
security-relevant code for no user-visible gain.
Checkout. Switching a working tree touches the index, the worktree and
HEAD. git checkout handles the awkward cases — submodules, sparse checkouts,
core.fileMode — and getting them wrong corrupts a user's working tree.
The consistent theme is that the write paths are where being wrong is
expensive, and git is the reference implementation.
Why anonymous clone stayed on gix¶
Because it works, and it is the one write-shaped operation that is really a
bulk read: negotiate refs, download a pack, write a fresh worktree. There is no
index to reconcile and no user data at risk, so gix's prepare_clone →
fetch_then_checkout → main_worktree sequence is sufficient. It also means a
file:// clone works with no external binary, which is what makes the crate's
own test suite runnable offline.
The asymmetry is visible from the outside: on a machine with no git,
Repo::clone(url, dst, CloneOptions::default()) succeeds and
Repo::clone(url, dst, opts.with_credential(cref)) fails. That is not a bug,
but it is surprising, so it is worth knowing before you diagnose it.
What this means for packaging¶
- Container images. A
FROM scratchor distroless image with a statically-linked binary will fail on any write path. Installgit, or restrict the tool to read operations. - Cross-compilation. The build does not need
git; the runtime does. A cross-compiled binary is fine to produce and will fail at the firstcommit. - Diagnosing it. A missing binary surfaces as a
RepoErrorwhosecausebeginsspawn `git commit`:followed by the OS error. It is not a dedicated variant, so a tool that wants to give a good message should check forgitonPATHat startup rather than pattern-matching that string. - Version sensitivity. Nothing parses git's stdout except
git rev-parse HEAD, which is stable. ButRepo::checkoutclassifies a missing revspec by matching English phrases in git's stderr —did not match any,unknown revision,not a valid object name,pathspec. Under a non-English locale that classification silently degrades toCheckoutFailed. SetLC_ALL=Cfor the process if you depend onRevspecNotFoundbeing returned.
Is this permanent?¶
It is not meant to be. The backend choice is internal precisely so it can
change: Repo::commit's signature and RepoError::CommitFailed's shape are
identical whether the work is done by a subprocess or by gix. Migrating a write
path to pure gix is a patch release, not a breaking change.
Which is also why you should not build behaviour on the current split. Do not
parse the cause strings, and do not assume a .git/index.lock will appear
where a subprocess would create one.