Skip to content

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_clonefetch_then_checkoutmain_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 scratch or distroless image with a statically-linked binary will fail on any write path. Install git, 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 first commit.
  • Diagnosing it. A missing binary surfaces as a RepoError whose cause begins spawn `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 for git on PATH at startup rather than pattern-matching that string.
  • Version sensitivity. Nothing parses git's stdout except git rev-parse HEAD, which is stable. But Repo::checkout classifies a missing revspec by matching English phrases in git's stderrdid not match any, unknown revision, not a valid object name, pathspec. Under a non-English locale that classification silently degrades to CheckoutFailed. Set LC_ALL=C for the process if you depend on RevspecNotFound being 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.