// ZVCS — GIT-SHADOWING SUPERSET VCS

zvcs v0.22.5 · one Rust binary named git · shadows stock git on PATH, no fork/exec · git-compat served via vendored gitoxide · superset: singleton per-repo-lane daemon, reactive autonomy, SQLite ledger + async queue, machine-wide status, cross-repo timeline/undo, tree snapshots, per-agent isolated worktrees · MIT · in active development

// Color scheme

>_ZVCS REFERENCE

A git-shadowing superset VCS in Rust. A single binary named git shadows stock git on PATH and serves subcommands natively via vendored gitoxide — there is no fork/exec of stock git. On top of git compatibility it adds the zvcs superset: coordination verbs stock git structurally cannot have, aimed at driving a large meta-repo of submodules under many concurrent automated agents. Early, in active development.

What it is

zvcs is a from-source VCS, not a wrapper. The git binary discovers and reads the same on-disk .git directory stock git does, so tools already on PATH (RustRover, gh, cargo) see identical behavior. Git-compat porcelain is ported incrementally on top of the vendored gitoxide (gix) library; when a subcommand is not yet ported the binary errors terse rather than falling through to stock git.

The world's-first leg is not "git in Rust" — gitoxide is already that. It is the superset coordination layer: a fair FIFO index-lock daemon, a reconcile-to-mainline submodule attacher, and forward-only gitlink bumps, served from the same binary that answers rev-parse.

The problem it solves

The meta-repo zvcs targets is a shell of git submodules driven by up to 16 concurrent automated agents. Stock git handles that topology poorly in three specific, reproducible ways, and each superset verb closes one of them:

index.lock contention

Git's O_EXCL lockfile fails a contended writer instead of queuing it. One machine-wide zdaemon with a per-repo FIFO lane serializes writers first-come-first-served; unrelated repos run fully in parallel.

Detached HEAD by default

git submodule update leaves submodules detached, orphaning work. zsync + the daemon's attach-scan keep every submodule attached to its mainline — even a dirty detached HEAD is rescued in place (no-clobber).

Constant pointer markers

Every submodule commit dirties the parent's gitlink. zbump + autobump do forward-only pointer bumps and commit them (clearing the (new commits) marker), coalesced on a file-watch, so agents never touch the root.

Agents colliding on one tree

N agents editing one meta tree collide on files, index, and HEAD. zworktree gives each agent a private, object-sharing worktree of the whole submodule tree — complete isolation, no re-clone.

Architecture

Two namespaces share one dispatch table (src/extensions/src/dispatch.rs):

git <subcommand>  →  dispatch  ┬─  superset verbs (z*, 119)  →  coordination · queue · ledger
                               │                              (zdaemon zsync zbump zcommit zpush
                               │                               zjobs zjob zrepos zreindex zstatus
                               │                               zlog zundo zclaim zwho zsnapshot
                               │                               zrestore zworktree zrepl zguard
                               │                               zprecache zppid zprocs …)
                               └─  git-compat porcelain (181) →  gitoxide (gix) library

Vendored gitoxide

src/ported holds the gix + gix-* crates in-tree as a self-contained workspace, excluded from the root and consumed as a path dependency. The gix/ein CLIs and gitoxide-core are removed; git is the only binary.

The shadow binary

src/extensions is the zvcs crate whose one binary is named git. It shadows stock git on PATH and routes every subcommand through dispatch::run.

RAII lock client

RepoLock::acquire (src/extensions/src/lock.rs) routes index-mutating ops through the daemon's FIFO and releases on drop. No reachable daemon degrades to a no-op guard.

Pure-Rust TLS fetch

gix is built with blocking-http-transport-reqwest-rust-tls so zsync's reconcile fetch runs over HTTPS with no curl/openssl C toolchain.

The zdaemon coordinator

One machine-wide zdaemon (state under ~/.zvcs/, socket ~/.zvcs/zvcs.sock) is the fair replacement for index.lock plus the host for reactive autonomy, the SQLite ledger, and the async job queue. The lock is per-repo — unrelated repos run in parallel; only same-repo writers serialize, first-come-first-served. It is reactive (no timers, no polling): a git pull/commit updates local refs, a notify file-watch fires, and the daemon reacts (attach, autobump, reconcile, status, hooks). Release is automatic (RAII on the client, socket-EOF on the daemon). Index writes also go through index.lock via gix-lock for stock-git interop. The wire protocol is line-based over the unix socket:

LineDirectionMeaning
ACQUIRE <id> <git-dir>client → daemonEnqueue on that repo's lane; answered GRANTED at its head.
RELEASE <id>client → daemonCurrent holder releases; the next waiter is granted.
SUBMIT <json>client → daemonQueue an async job (zcommit/zpush); answered JOB <id>.
JOBSTOP / JOBRESTART <id>client → daemonCancel / re-enqueue a job.
STATUS / STOPclient → daemonSnapshot / shut the daemon down.

Autonomy is gated by [zvcs] gitconfig. Off unless set: autoreconcile, autobump, autocrawl, autostatus, autohook, autodups, hook. On by default: precache (precompute the log caches when a watched repo's refs move — see Performance). Tunables: interval (debounce), statusinterval, watchmru, crawlroots, worktreebase. UI, not daemon behaviour: replvimode (vi keys in git zrepl), topscheme and toppalette (git ztop colours, written by its own scheme picker). Headless failures are recorded in the ledger and surfaced on your next git command.

Usage

# build, then put the shadow binary first on PATH — from here on,
# `git` IS zvcs (that is the whole point: it shadows stock git)
cargo build
export PATH="$PWD/target/debug:$PATH"

# git-compat: resolve HEAD against the on-disk .git via gitoxide
git rev-parse HEAD
git rev-parse --abbrev-ref HEAD

# superset: the singleton fair-lock coordinator (full control surface)
git zdaemon start
git zdaemon status       # holder / lane snapshot
git zdaemon info         # pid, socket, paths, config
git zdaemon ping         # exit 0 if live (scriptable)
git zdaemon restart      # respawn, re-reading config
git zdaemon log -f       # tail ~/.zvcs/zvcs.log
git zdaemon stop

# coordination: reconcile submodules to mainline (attached, ff-only) + bump pointers
git zsync                # reconcile submodules
git zbump                # forward-only gitlink bumps (+ commit)
git zup                  # bring the whole tree to latest origin/main

# repo index + machine-wide status
git zreindex             # crawl for git repos
git zrepos               # list them (pipe-clean)
git zstatus --all        # instant status across every indexed repo

# async queue + ledger
git zcommit file.rs -m "msg" --push
git zjobs                # recent jobs
git zjob 42              # one job (zjob stop|restart 42)

# multi-agent, timeline, snapshots, isolated worktrees
git zclaim               # lease this repo for $ZVCS_SESSION
git zwho                 # who holds what
git zlog                 # cross-repo reflog timeline
git zundo                # rewind this repo one step
git zsnapshot before     # tree-wide restore point (committed HEADs)
git zrestore before      # restore the whole tree
git zstash               # park uncommitted work across the tree
git zunstash             # restore it (LIFO)
git zworktree add agent3 # private isolated tree for an agent

Performance — how the read path beats stock git

Every read command measured against stock git is ahead, from 1.19x to 16.05x. The diagrams below are the whole explanation: what the time is actually spent on, the five levers that move it, and the three things this does not fix. Numbers come from scripts/bench.sh on zshrs (6,376 commits) against Apple git 2.50.1, 18 cores, release build.

The shape of the problem

A read-only git command spends almost none of its time on git. It spends it on per-item work: decode this commit, diff that tree pair, count lines in this blob, abbreviate that id. The items are independent, the objects are immutable while the command runs, and most of the answers are the same every time they are asked for.

Stock git does that work on one core, from scratch, on every invocation, with nothing of itself alive between two commands. Those three properties are what the five levers below attack — and only the first of them is ordinary optimization.

WHERE A READ COMMAND SPENDS ITS TIME parse argv, config walk commit-graph per-item work — the whole cost decode commit · diff trees · count blob lines · abbreviate render in walk order write out block-buffered THREE PROPERTIES OF THAT WORK 1 · the items are independent a blob pair is diffed in isolation → lever A: use every core 2 · the inputs are immutable objects are content addresses → lever C: memoize, forever 3 · the answer predates the question a commit exists before it is read → lever D: compute it early git cannot take 1 (single-threaded diff), takes 2 only for its own caches, and cannot take 3 at all — no part of git is running between two commands.
The levers are not tricks layered on a slow core; each one follows from a property the work already has. Lever B (do less work) and lever E (do not block on bookkeeping) appear in their own sections below.

A · every core

Patches, per-file analysis, pickaxe scans and record rendering are fanned across a worker pool. git's diff and log machinery is single-threaded.

B · less work

-S counts a needle in blobs instead of rendering patches; name-only formats stop reading blobs they never print.

C · a cache

Values that are pure functions of immutable objects are stored in memory-mapped rkyv images and never recomputed — machine-wide, shared across clones. A hit is a binary search and a slice into the mapping: nothing decoded, nothing allocated.

D · a daemon

Those values are computed when a repo's refs move, before anyone asks. Structurally impossible for git.

E · off the critical path

Filling a cache is bookkeeping. The rows are queued to a writer thread; the command returns without waiting on a transaction.


Lever A — fan the work across the machine

The object store cannot change while a read-only verb runs, so nothing forces the per-item work to be sequential. Four paths use the pool: log -p patches, diff per-file analysis, log -S/-G scans, and shortlog record extraction.

Workers pull from a shared cursor rather than taking a fixed slice. One commit that rewrites a large file outweighs a hundred that touch a line each, so a static split leaves every worker but one idle:

FIXED SLICE (what a static split gives you) wall clock → w0 w1 w2 one huge commit w3 finish = the slowest slice SHARED CURSOR (what zvcs does) w0 w1 w2 finish = total ÷ workers each worker takes the next index the moment it is free
Each worker owns a repository handle and a blob platform — neither type is Sync, and a handle clone shares the underlying object store rather than re-opening it. ZVCS_THREADS pins the count; ZVCS_THREADS=1 forces the sequential path and produces byte-identical output, which is asserted by test.

Output stays a stream. log -p renders a window of commits ahead of the writer and hands them out in walk order, so memory is bounded by the window rather than by the length of the history:

LOOK-AHEAD WINDOW — PARALLEL INSIDE, ORDERED OUTSIDE walk node list window: next N commits 64 patches · 256 records worker worker worker worker shared cursor over the window slots, by index results land in order emit one record stdout 64 KiB buf window refills only when the reader runs past it — memory is O(window), not O(history)
The same shape covers commit records (--oneline, %s, the default format): reading 6,000 commit objects is the entire cost of those formats, and none of those reads depends on another.

Lever B — stop doing work nobody asked for

Two cases mattered more than the threading. The pickaxe was building a full patch for every candidate commit and then counting occurrences in the +/- lines. git never does that: has_changes counts the needle in each side's whole blob and keeps the file when the two counts differ — the needle's position is irrelevant.

git log -S <needle> — BEFORE commit tree diffchanged files render full patchMyers + hunks + text count in ± lines keep / drop git log -S <needle> — AFTER commit tree diffno renames yet count in both blobsvectorized substring scan counts differ?cheap over-approximation keep / drop only if the cheap pass said "maybe": re-diff WITH rename detection (50% similarity, git's default) moved content holds the needle equally on both sides — pairing only CANCELS, never creates Merges are dropped outright: git renders no diff for a merge without -m/-c/--cc, so a merge can never match — and merges are the largest diffs in the history.
Rename detection is expensive and almost never changes the answer, so it runs only for a commit the cheap pass already flagged. Result: log -S return over 6,376 commits — 2.673 s against git's 7.209 s, byte-identical hit sets across six needles including one with 5,320 hits.

The second case: --name-only, --name-status, --raw and --summary were analyzing blobs to produce line counts that those formats never print. Skipping the analysis took diff --name-only HEAD~5 from 26.7 ms to 9.5 ms in isolation — the change list was already in hand.


Lever C — a zero-copy cache for values that cannot go stale

Git objects are content addresses. That makes certain answers permanent, not merely cacheable-with-invalidation: there is no event that can make them wrong.

THE CACHE — ~/.zvcs/cache/*.rkyv (mmap'd, zero-copy) treediff key: (old_tree, new_tree, counts) value: status + ± tallies per file the tallies cost one blob read each abbrev key: (object id, hex_len) value: the unique short form hex_len is in the key: the repo grows blame key: (commit, path, algo) value: run-length attribution algo is in the key: it changes the answer Every key is made of content addresses, so an entry is valid in EVERY clone that holds those objects — the cache is machine-wide, not per-repository, and a fresh clone inherits everything already computed. Not cacheable, and not pretended to be: a worktree diff. Working-tree bytes are not content-addressed, so there is no stable key — that path is served by lever A instead.
One cache serves several formats: the tree-diff entry feeds --stat, --numstat, --shortstat, --name-only, --name-status and the path-limited traversal predicate. Writers append to a journal and fold it into the image once it grows; readers take no lock, and every race degrades to a miss, never to a wrong answer.

Lever D — compute it before it is asked for

This is the one advantage that is structural rather than algorithmic. The daemon is already awake and already learns that a watched repository's refs moved; the values above can be computed at that moment instead of when someone runs log --stat.

WHEN THE WORK HAPPENS fetch / commit lands user runs `git log --stat` stock git nothing of git is running full cost, every time zvcs, no ledger computes + records zvcs, warm reads the ledger zvcs + daemon precache on ref-change already paid, off the user's clock zvcs.precache (on by default) warms the newest 200 commits off the watcher thread · `git zprecache [-n]` does the same pass on demand
Measured with an empty cache: log --stat -n 150 takes 432 ms cold, 12.0 ms warm, and git zprecache -n 150 — the work the daemon does on its own — takes 0.52 s once.

Lever E — never make the caller wait on bookkeeping

A cache row is written only because the answer was already computed. Making the user wait for it is backwards — and the first version did worse than wait: it opened a connection and re-ran the schema batch per row, in the middle of the walk. The cold run, the one the cache cannot help yet, was carrying the entire cost of building it.

BEFORE — INLINE, PER ROW commit N open_rw+ schema txn1 row commit N+1 open_rw+ schema txn1 row … × every commit the walk stalls on the disk once per commit — and the read side reopened the ledger per lookup too AFTER — QUEUED, BATCHED, WAITED ON ONCE commit N commit N+1 commit N+2 … walk never touches the disk render + write out user has the answer flush (join) whatever is left writer thread — one connection, one transaction per drain runs concurrently with the walk, the render and the pager
The wait at the end has to exist — a detached thread does not outlive the process, and a cache that never persists is just a slower uncached path. But by then the writer has had the whole command, and usually the whole pager session, to get ahead. Cold log --stat -n 150: 831 ms → 402 ms, against git's 532 ms.

Results

zshrs (6,376 commits) · stock git 2.50.1 · 18 cores · 12 runs after 3 warmups · release build · both binaries measured in one interleaved hyperfine run so machine load moves them together. Regenerate with scripts/bench.sh <repo>.

SPEEDUP OVER STOCK GIT — HIGHER IS BETTER 1x 2x 4x 8x 16x log --stat -n 3016.05x status10.37x blame README.md4.55x log -S return3.59x log (default format)3.31x log --oneline3.21x log --format=%s2.90x log --format=%h2.29x log -p -n 201.86x cat-file -p HEAD1.56x shortlog -s1.50x describe1.40x show HEAD1.30x tag -l1.24x diff --stat HEAD~51.19x
Also measured and ahead, omitted from the chart for space: for-each-ref 1.35x, ls-files 1.35x, rev-list --count 1.34x, diff --name-only HEAD~5 1.20x.

Which lever moved which command

CommandLeversWhat actually changed
log --statC, D, Etree-pair tallies memoized; daemon warms them; writes no longer stall the walk
statusalready ahead: gitoxide's index + worktree scan, no per-file fork
blameC, D, Erun-length attribution memoized per (commit, path, algo)
log -S / -GA, Bblob counting instead of patch rendering; rename gate; merges dropped; scan fanned out
log --oneline, %s, defaultAcommit records rendered 256 at a time across the pool
log -pA64-patch look-ahead window, emitted in walk order
diff --stat (worktree)Aper-file analysis fanned out — no cacheable key exists for a worktree side
diff --name-only, --rawBstopped reading blobs for counts those formats never print
tag -lBnames-only path: a plain listing no longer decodes and peels every tag object
shortlogAper-commit record extraction fanned out

Cold versus warm

Commandzvcs coldzvcs warmgit
log --stat -n 30121.6 ms8.2 ms142.0 ms
log --stat -n 150432.2 ms12.0 ms544.0 ms
blame README.md72.1 ms10.4 ms43.7 ms

Cold means the cache is deleted before every single run — the worst case, and not one a running daemon leaves behind. Cold blame is the one row that loses to git, and it is reported as measured: gix's blame walk is slower than git's, so the first blame of a file costs ~1.7x git. The cache is what turns it around on the second.


What this does not fix

Hunk boundary placement still differs from git

On 31 of 400 sampled commits, a patch places an ambiguous hunk boundary differently than git does — same content, same file list, a valid diff, but not byte-identical, and it shifts --stat counts by a line or two on those commits. The vendored imara-diff slides a group to a different position than git's xdl_change_compact. It is not the indent heuristic: disabling that in git does not reproduce our placement either. Porting the compaction is the fix.

Nothing here speeds up writes

Every measurement on this page is a read-only command. Mutating verbs are excluded from the benchmark set on purpose — a benchmark has to be repeatable, and a mutating verb changes the repository under its own measurement.

Parallelism is not free under load

zvcs deliberately uses every core, so a busy machine compresses its lead rather than git's. The figures above were taken at load ~20 on an 18-core box; on an idle machine the ratios are larger, not smaller. ZVCS_THREADS pins the worker count when that trade is not wanted.


Status & roadmap

Early and in active development. The table reflects the current state of the tree.

ComponentStateNotes
Shadow git binary + vendored gitoxideImplementedSingle binary named git; two-namespace dispatch; in-tree gix/gix-* workspace.
Singleton daemon + per-repo FIFO lanesImplemented~/.zvcs/; parallel across repos; socket-EOF auto-release; reactive file-watch, no polling.
Coordination — zsync / zbump / attachImplementedff-only reconcile; forward-only bump + commit; detached-HEAD attach-scan (dirty-safe).
SQLite ledger + repo index + crawlerImplementedzrepos/zreindex (pipe-clean, prunes deleted); WAL ledger; notify-on-next-command.
Zero-copy derived-answer cache (~/.zvcs/cache/*.rkyv)Implementedmmap'd rkyv images for tree diffs, blames and abbreviations; lock-free reads, journal + flock'd compaction for writes.
Async queue — zcommit/zpush/zjob(s)ImplementedBounded job pool; ls-refs push pre-flight; zjob stop/restart; sync fallback.
Multi-agent, status, timeline, snapshotsImplementedzclaim/zwho; zstatus --all; zlog/zundo; zsnapshot/zrestore; typed hooks.
Per-agent isolated worktrees (zworktree)ImplementedObject-sharing linked worktrees of the whole submodule tree; stock-git interop verified.
Git-compat parityOngoingEvery subcommand dispatches natively; per-flag parity with stock git is measured by the harness and is the work that remains — see the report.

Building from source

zvcs builds as a standalone Rust workspace:

# clone
git clone https://github.com/MenkeTechnologies/zvcs
cd zvcs

# build and shadow stock git on PATH
cargo build
export PATH="$PWD/target/debug:$PATH"

# smoke-test git-compat against this repo's own .git
git rev-parse HEAD

src/ported is a self-contained workspace excluded from the root and consumed by src/extensions as a path dependency. gix is built with the blocking-http-transport-reqwest-rust-tls feature so zsync can fetch over HTTPS with a pure-Rust TLS stack.

License

zvcs is MIT licensed — free and open source. See LICENSE.

Repository & links