Grit's mission is a library-focused Rust reimplementation of Git, yet today many
operations spawn a child grit process where a function call into the same code
would do. This document inventories why we spawn, what can be eliminated, what
must stay, and the order of attack.
Status: Phase 0 is done — production code no longer invokes the system git
binary anywhere. All remaining spawns are either grit re-invoking itself
(via grit_exe::grit_executable()) or genuinely external programs (editors,
hooks, ssh, gpg, …).
- Performance.
fork/exec+ full CLI re-init (config parse, repo discovery, clap) costs milliseconds per call; commands likecommit -vspawn 2–3 children, submodule recursion spawns one per submodule per level. - Correctness. Child processes communicate through argv + env vars + stdout
text that we then re-parse (e.g.
commitparsesdiff --name-statusoutput). Each round-trip through text is a fidelity risk and was the cause of the flag-ordering bug found while removing the system-git spawn fromcommit. - Library mission. Every spawn marks a place where the functionality is not callable as a library API. Killing the spawn forces the API to exist.
Every self-spawn exists for one of five reasons. Each has an in-process answer:
| # | Root cause | Example | In-process replacement |
|---|---|---|---|
| 1 | Convenience — output of another command is wanted as text | commit -v runs grit diff -p to build the template |
Call the diff machinery directly with an impl Write sink |
| 2 | Different repo context — operate on another repository (submodules) | rm runs grit status --porcelain inside the submodule |
Open a second Repository handle; no global state needed |
| 3 | Process-global state isolation — env vars (GIT_DIR, GIT_INDEX_FILE), cwd |
most self-spawns set/strip env on the child | Thread an explicit context struct instead of reading std::env |
| 4 | Protocol sidedness — upload-pack/receive-pack on the far end of a pipe |
local fetch spawns grit upload-pack |
Run the endpoint as a function over an in-memory duplex stream |
| 5 | Process lifetime — daemons, background tasks, crash isolation | maintenance --detach, credential-cache daemon, bisect running user scripts |
Keep as spawns (legitimately a different lifetime) |
Only (1)–(4) are killable. (5) stays.
commands/rm.rssubmodule_status_dirty()— wasgit status --porcelain, now spawnsgrit_executable().commands/commit.rsverbose-template diffs — was the host/usr/bin/git diff(via the removedgit_binary_for_status()), now spawnsgrit_executable()with--color=neverand flags ordered before the revision (grit's trailing-flag re-parse indiff.rsonly knows a subset of options — see Phase 1).- Remaining external-git references are deliberate:
scalar.rs(interops with a system git by design) andbin/test_httpd.rs(test-onlygit-http-backend).
These spawn grit only to capture stdout text. The callee lives in the same crate (grit-git), so the fix is to factor the command's core into a
fn run_x(repo: &Repository, opts: X, out: &mut impl Write) -> Result<...>
that both the CLI entry point and the internal caller use. No crate surgery.
| Spawn site | Spawns | Replace with |
|---|---|---|
commands/commit.rs (verbose template, ×2) |
grit diff [--cached] -p |
patch formatting in commands/diff.rs driven by grit_lib::diff |
commands/commit.rs (template status) |
grit diff --cached --name-status |
grit_lib::diff tree-vs-index entries — the caller already re-parses into DiffEntry-shaped data; skip the text round-trip entirely |
commands/rm.rs submodule_status_dirty |
grit status --porcelain in submodule |
open submodule Repository, reuse status.rs worktree/untracked scan; return a bool, never format |
commands/status.rs |
grit submodule summary |
submodule summary as a function |
commands/push.rs (×3) |
update-index --refresh, diff-files, diff-index |
index refresh + dirty checks via grit_lib::index/diff |
commands/mv.rs, reset.rs, read_tree.rs |
update-index / read-tree / submodule update helpers |
index ops in-process |
commands/checkout.rs/restore.rs |
grit apply [--cached] |
factor apply's engine to take buffers |
commands/rev_parse.rs, range_diff.rs |
grit rev-parse / grit log |
grit_lib::rev_parse / log machinery |
Definition of done per row: the spawn is gone, the upstream test files covering
the command stay at their current pass counts (the harness TOMLs in
data/tests/ are the baseline).
Watch out: some upstream tests observe child processes (GIT_TRACE2
child_start counts, e.g. t2080's checkout--worker accounting). Before
removing a spawn, grep git/t/ for trace expectations on that command.
pack-objects, index-pack, unpack-objects are spawned from gc, repack,
fetch, receive_ingest, multi_pack_index, maintenance, and the upload
path. They are spawned because their interfaces are streams (object list in,
pack bytes out). The library shape:
grit_lib::pack::write_pack(repo, objects, &mut impl Write, PackOpts) -> Result<PackId>
grit_lib::pack::index_pack(repo, &mut impl Read, IndexPackOpts) -> Result<PackId>
grit_lib::pack::unpack_objects(repo, &mut impl Read, UnpackOpts) -> Result<Stats>Most of this logic already exists behind the CLI commands; the work is separating arg-parsing/stdout from the engine and exposing progress via a callback instead of stderr. One real subprocess benefit to preserve or consciously drop: memory isolation for very large packs (a child's allocator pages are returned to the OS on exit).
For file:///local fetch and push, we spawn our own upload-pack /
receive-pack purely to get a pkt-line conversation over pipes
(fetch_transport.rs, file_upload_pack_v2.rs, send_pack.rs,
ext_transport.rs fallback, commands/http_backend.rs, grit-protocol).
The endpoints should be functions over generic streams:
grit_protocol::upload_pack::serve(repo, &mut impl Read, &mut impl Write, Opts)
grit_protocol::receive_pack::serve(repo, &mut impl Read, &mut impl Write, Opts)Then local transport connects client and server with an in-memory duplex
(thread + os_pipe or a ring buffer), and remote transports (ssh, http) keep
the same functions wired to sockets/child stdio. This also collapses the
nested re-invocation in receive_pack.rs:981. SSH and ext:: keep spawning —
the far side is genuinely another machine/program.
clone, fetch, push, submodule update/status, read-tree spawn one
grit per submodule (×depth). In-process needs a Repository opened per
submodule and no reliance on process-global env or cwd anywhere in the call
path — which is the real blocker, hence Phase 5.
The reason self-spawning is easy today is that a child process gets a fresh env/cwd for free. To call instead of spawn we need:
- Env honored only at the boundary.
GIT_DIR,GIT_INDEX_FILE,GIT_WORK_TREE,GIT_CONFIG_PARAMETERSread once inmain, snapshotted intoRepository/context structs. Grep target:std::env::varoutsidemain.rssetup (it is pervasive; burn down incrementally, path-by-path as each phase needs it). - No cwd dependence. All library paths absolute, derived from
Repository. (Command::current_diris the tell.) - Output sinks. Engines write to
&mut impl Write+ progress callbacks, neverprintln!/stderr directly. - Errors, not exits.
Resultwith typed errors instead ofstd::process::exitinside engines, so an in-process callee can't kill the caller. - Trace2 spans instead of child processes. Replace
child_startevents with region/span events where tests permit; where tests count children, keep the spawn or fix expectations only via the allowedtest_expect_failureflip rule.
- User-configured programs: editor, pager, difftool/mergetool, browsers,
askpass/credential helpers, gpg/ssh signing, merge drivers, clean/smudge
filters,
ext::/remote helpers. - Hooks (
grit-lib/src/hooks.rs) and shell aliases (alias.rs!form), rebaseexec, bisect run scripts — running user code in our address space is wrong. - System utilities:
stty,iconv,crontab/systemctl/launchctl,man, ssh. - Daemons / detached lifetimes: maintenance background runs,
credential-cache daemon,
simple_ipc,git daemonper-connection children (though per-connection could become threads later). scalar's system-git calls — interop with a real git install is the point.
Phase 0(done in this change)- Phase 1 rows, one command at a time — each is a self-contained PR with an
existing test file as its acceptance gate (
t7507,t3600,t7508, …). - Phase 5 items 3–4 opportunistically as Phase 1 touches each engine.
- Phase 2 (pack streams) — unblocks gc/repack/fetch/receive hot paths.
- Phase 3 (local protocol in-process).
- Phase 4 (submodules), last, after env/cwd hygiene is real.
A simple ratchet: grep -rn "grit_executable()" grit/src grit-lib/src grit-protocol/src | wc -l
(currently 70 call sites). CI could assert the count never goes up, and each
phase lowers the floor. A companion metric is wall-clock on spawn-heavy tests
(t7400/t7406 submodule suites, t5601 clone).