A rewrite of the Git Graph VS Code extension with its Git backend in Rust, loaded into the extension host as a Node-API addon through napi-rs and reading repositories with gix.
The original extension answered every question by spawning a git process and parsing its
output. This one reads the object database, the refs and the index directly, in-process, from a
repository handle that stays warm for the whole editor session. Every repository read — the
view load, commit/stash/uncommitted details, comparisons, file contents and diffs, search, config
— is served by the Rust engine; anything the engine does not reach, and the whole write path
(checkout, merge, rebase, push, …), goes through the git CLI behind the same interface.
Install it from the
Visual Studio Code Marketplace
(search for "Git Graph RS") or Open VSX,
or download a VSIX from GitHub Releases
and install it with code --install-extension git-graph-rs-<version>.vsix.
Bug reports, feature requests and questions go to the issue tracker.
The engine in native/core is not confined to this extension: it is linked in-process into
Git Graph Studio, a standalone desktop
application (Tauri 2 + TypeScript + Rust) that hosts it inside a VS Code-class workbench —
a File Explorer with git status decoration, Source Control, a tabbed editor suite with
split groups, an integrated terminal, a VSIX / .ggx extension platform and a CAN trace
analyzer — with the Git Graph view served by this extension's webview, unchanged, behind an
acquireVsCodeApi shim. Every repository read in the app crosses the same engine; writes go
through the git CLI, as they do here.
Beyond the Rust engine, these are new relative to the original mhutchie/vscode-git-graph:
- A Gerrit integration, rebuilt. Per-repository change-ref fetching, a change badge on every
commit with its change/patchset number and Code-Review/Verified scores, a review dialog with the
full NoteDb event timeline and an "Open in Gerrit" button, and an open/merged/abandoned/WIP
status filter. The NoteDb meta histories are parsed in-process by the engine; the remote is only
contacted when the user asks (Fetch, enabling the integration, changing its fetch settings) —
every plain view load works offline from the locally cached refs. Configured through
git-graph-rs.gerrit.remote,git-graph-rs.gerrit.fetchLimit(default 20, overridable per repository) andgit-graph-rs.gerrit.showReviewProgress. - Gerrit commands in the Source Control view: push the current branch for review to
refs/for/<branch>(adding a Change-Id to HEAD first, exactly as Gerrit's commit-msg hook does, and never amending a commit already pushed to a remote), and install the commit-msg hook from the Gerrit server. - Data-loss protection on the write operations that can silently strand work — see Data-loss protection.
- A Simplified Chinese interface (
git-graph-rs.interfaceLanguage: auto / English / 简体中文) covering the webview, the extension host messages and the Source Control menus. - Instant first paint, progressive completion. A view load renders the local branch and tag
pills immediately from a local-only ref scan, merges the remote pills in when the full scan
arrives, and chains the "Uncommitted Changes" row and the Gerrit stages onto the same pipeline.
Commit details render their file list first and settle the
+N/-Mline counts progressively. - A graph that stays current by itself. A repository file watcher plus a 5-second background poll of the ref/HEAD/stash signature means a commit made from a terminal or another tool appears on the graph within seconds. Re-showing an already-rendered view soft-refreshes it instead of regenerating the HTML, so switching tabs back never blank-flashes.
- Binary file comparison: a streaming hex view that byte-compares the matched equal suffix, and a picture mode that dyes differing pixels and reports PSNR.
- Amend Last Commit and Reset Current Branch to Remote (soft) in the Source Control view.
- Runs without Git installed — the whole read path is served in-process by the engine; write
operations report that they need Git. Conversely, on a platform with no prebuilt engine binary
the extension runs entirely over the
gitCLI.
The numbers below compare the backend the extension actually ships with what the original extension does:
- shipped — what
createBackend()hands the extension on this machine: the Rust engine wrapped in thegitCLI fallback. An operation the engine declines is timed as the user experiences it, fallback spawn included, not as the engine alone. - git CLI — the bare
CliBackend, onegitspawn per call, which is what the original extension does for everything.
Both sides are driven through the same GitBackend interface with the same arguments;
node scripts/bench.mjs <repo> --all reproduces the tables. Each number is the median of 11
runs after an untimed warm-up (the engine's caches and the OS page cache are warm, so this is the
steady-state interaction, not the first cold open).
Environment: Windows 11, Intel i7-14650HX, Node 24, git 2.50.1, extension 1.0.23.
| operation | git CLI | shipped | speedup |
|---|---|---|---|
| view load (repoInfo + first page — what the user waits for) | 331.1 ms | 69.0 ms | 4.8× |
| getRepoInfo (branches/tags/remotes/stashes) | 170.9 ms | 11.3 ms | 15.1× |
| getCommits (a page of the graph) | 203.9 ms | 63.8 ms | 3.2× |
| getRefs | 141.2 ms | 13.6 ms | 10.4× |
| getCommitDetails | 129.1 ms | 3.3 ms | 38.7× |
| getLineCounts (the details view's deferred counts) | 46.7 ms | 4.4 ms | 10.6× |
| getCommitBodies (50 commits) | 76.9 ms | 12.2 ms | 6.3× |
| getCommitSummaries (50 commits) | 116.5 ms | 11.0 ms | 10.6× |
| getCommitSubject | 41.5 ms | 0.7 ms | 60.6× |
| searchHistory ('' matches everything) | 74.8 ms | 36.2 ms | 2.1× |
| getConfig | 68.2 ms | 0.2 ms | 432× |
| getStashes | 47.3 ms | 0.5 ms | 94.3× |
| getUncommittedChangeCount | 59.7 ms | 15.1 ms | 3.9× |
| compareCommits | 64.2 ms | 25.5 ms | 2.5× |
| countCommitsBefore | 70.7 ms | 35.1 ms | 2.0× |
| getCommitFile | 45.3 ms | 1.3 ms | 36.2× |
| getCommitFileDiff | 98.1 ms | 4.0 ms | 24.6× |
| getCurrentBranchUpstream | 45.4 ms | 0.7 ms | 68.3× |
| getRemoteUrl | 42.6 ms | 0.1 ms | 458× |
| operation | git CLI | shipped | speedup |
|---|---|---|---|
| view load (repoInfo + first page of 300) | 429.7 ms | 96.5 ms | 4.5× |
| getRepoInfo (branches/tags/remotes/stashes) | 198.7 ms | 31.8 ms | 6.2× |
| getCommits (a page of the graph) | 240.5 ms | 55.8 ms | 4.3× |
| getRefs | 118.8 ms | 29.7 ms | 4.0× |
| getCommitDetails | 125.7 ms | 0.7 ms | 185× |
| getLineCounts (the details view's deferred counts) | 47.8 ms | 0.9 ms | 53.5× |
| getCommitBodies (50 commits) | 67.2 ms | 0.7 ms | 95.2× |
| getCommitSummaries (50 commits) | 66.8 ms | 0.5 ms | 123× |
| getCommitSubject | 51.1 ms | 0.1 ms | 353× |
| searchHistory ('' matches everything) | 74.7 ms | 37.1 ms | 2.0× |
| getConfig | 69.6 ms | 0.1 ms | 481× |
| getStashes | 50.7 ms | 0.4 ms | 133× |
| getUncommittedChangeCount | 52.8 ms | 12.0 ms | 4.4× |
| compareCommits | 49.8 ms | 0.9 ms | 55.0× |
| countCommitsBefore | 63.4 ms | 33.8 ms | 1.9× |
| getCommitFile | 48.3 ms | 0.2 ms | 217× |
| getCommitFileDiff | 96.0 ms | 0.6 ms | 169× |
| getCurrentBranchUpstream | 47.8 ms | 0.6 ms | 79.5× |
| getRemoteUrl | 44.9 ms | 0.1 ms | 334× |
| getTagDetails (annotated tag) | 46.7 ms | 0.3 ms | 149× |
getSubmodules is omitted: on a repository without a .gitmodules the CLI backend answers from
the working tree without spawning anything, so both sides take ~0.1 ms.
- The view load — the number the user waits for on every open — is 4–5× faster. Page size is capped at 300 commits, so the shipped backend's cost stays roughly flat as the history grows, while the CLI's is dominated by ref scanning and pack reads on top of several process spawns.
- Single-object reads win by one to two orders of magnitude (
getConfig,getRemoteUrl,getCommitSubject,getStashes,getCurrentBranchUpstream,getCommitDetails). On the CLI side each costs onegitspawn — a ~40–70 ms floor on Windows — while the warm repository handle answers in well under a millisecond. The gap does not narrow as the repository grows, because the spawn is the cost. - Full-history walks are where the two come closest (
searchHistorywith a pattern that matches everything,countCommitsBefore): the work is proportional to the history andgit's walker is highly optimised. The shipped backend still leads, by about 2× rather than 100×.
Four things account for the gap, each found by measuring rather than by guessing:
- The repository stays open. A
gitspawn re-reads the pack index files before doing any useful work, and a single view load makes several such calls. The engine opens a repository once and keeps its pack indexes and object cache resident. (repository.rs) - Refs are filtered by name before any object is read. A ref the view is not showing costs a
string comparison and nothing more, and remote-tracking refs are never peeled. On a Gerrit
remote whose
changes/tree holds tens of thousands of refs, this is the difference between reading all of them and reading none. (refs.rs) - Revisions that are already hashes skip the revspec parser. The tips of a view load come straight from the refs that were just read, so they are looked up directly rather than sent through gix's revision parser — which was, on its own, 4× the cost of the whole walk.
- One call crosses the boundary per request, not one per commit. Results are serialised once
in Rust and parsed once in JavaScript rather than built property by property over Node-API.
(
native/node/src/lib.rs)
Three rules hold the shape together:
- Rust is the Git engine and nothing else. It knows nothing about VS Code, never shells out,
and is exercised by
cargo testwith no Node in the picture. - TypeScript does the UI and the VS Code API, and never parses Git itself.
- gix is an implementation detail. Nothing above
native/corenames it.
The lane layout — which column a commit's dot sits in — deliberately stays in the webview: it is a rendering decision that depends on the viewport.
The engine's whole read surface sits behind one method table
(native/core/src/dispatch.rs). The Node addon (native/node,
through napi-rs) serves typed exports for every read — what the extension's backend consumes
through src/backend/addon.ts — plus request, the same surface as one JSON call
({"method": "loadCommits", "params": {…}}) for REPL probes and test harnesses that want the
engine without a binding per method. Both routes share the table, so they cannot drift.
Failures ride the same shape on both: a typed export throws Kind: message and the single
interface answers {"error": "Kind: message"} in band — the same kinds, so one parse decides
between fallback and error dialogue.
createBackend() returns the Rust engine wrapped so that anything it cannot answer reaches the
git CLI instead. Only two kinds of failure are fallen back over: this is not a repository I can
open, and I do not implement this. A genuine Git failure — a bad revision, a corrupt object —
is the answer, and re-running it through git would produce the same failure more slowly.
On a platform with no prebuilt binary the CLI backend is used alone and the extension behaves
exactly as the original did; the Settings widget's Backend section shows which areas run on
which backend on this machine. Which operations the engine serves and which still spawn git is
documented in docs/BACKENDS.md.
The write path (checkout, merge, rebase, stash operations, remotes, tags) is not on the
GitBackend interface yet and still spawns git directly, exactly as the original did;
GAPS.md is the inventory of what is missing.
Correctness is checked against Git itself: the engine's tests build fixture repositories with the
git command line and compare every reader to what git reports, and tests/backends.test.mjs
runs both backends over the same repository through the same interface, asserting they agree
field by field — the webview cannot tell which one it is talking to, so any disagreement is a
user-visible behaviour change.
- Signatures are verified by Git when a CLI is available. The engine reads signature presence
in-process; verification is delegated to Git/GPG so the status, key id and signer match the
user's keyring. Without Git the status stays
E("cannot be checked"). - Ordering reads a bounded window. gix's traversal offers no topological guarantee, so a window of commits — a multiple of the requested page — is collected and re-ordered here. Ordering is exact within the window; a commit never appears before one of its children.
- A comparison against the working tree shows no line counts, because getting them means hashing every worktree file, which costs more than the counts are worth on the critical path.
- Line counts arrive after the file list. Opening a commit renders its files first and settles
the
+N/-Mcounts progressively; everywhere except a working-tree comparison they are exact once settled.
Some write operations can lose work that no ref keeps reachable — silently, because the git
command that does it exits successfully. Those operations do not run on the first click: the view
shows a warning dialog stating the actual recoverability of the case, and only re-sends the action
when the user confirms. The guarded operations:
- Leaving a detached HEAD that has its own commits — switching branch, checking out another
commit, or creating a branch elsewhere with checkout. After the switch those commits are
reachable only from the reflog, and only until
git gcprunes them. Creating the branch at HEAD anchors them, and so does a stash whose base is on them, so neither is asked. - A hard reset while the working tree has uncommitted changes. Those contents are recorded in no reflog.
- A force push. The commits the remote has that the push does not contain become unreachable
there.
--force-with-leaseis the guarded variant and is not confirmed a second time.
Every control in the Git Graph view — buttons, context menus, dialogs, widgets — can drive and
verify itself, with per-action timings, to answer two questions about a real install: does
every control still work? and how long does each take? It is a packaging variant: the
automation build ships it, the default build does not. build-and-install.bat automation
on Windows, ./build-and-install.sh automation on macOS/Linux (the optional argument
packaging npm run package:automation) produces the
automation-enabled vsix and installs it; the original bats and CI (npm run package) package
the extension without it — the automation modules are moved out of the package and the
contributions (button, command) are absent. The same source loads fine either way. There is no
external interface: the suite runs entirely in-process (no sockets, no ports, no drivers).
Open the Git Graph view and click the beaker button in the editor title bar (command Git Graph RS: Run Automation Test). The extension runs the whole suite in-process — real DOM clicks through the view itself — shows progress in a notification, and opens a report page when it finishes: summary cards, a per-action table with timings and pass/fail/skip badges, a filter box, and Save as HTML / Save as JSON buttons.
The read suite (no repository changes) runs against any repository and is repository-agnostic:
branches, tags, stashes, authors, files and find queries are resolved from the live repository
state, rows and ref labels are scrolled into the windowed view before they are driven, and an
action whose prerequisite the repository or view does not offer — no remote, no stash, no
(annotated) tag, no file changes, a single branch, a second repository, a clean working tree, a
history that fits one page — is skipped with the reason shown in the report rather than
failed. A control that genuinely cannot be reached fails fast with the step error, never a
30 s timeout. State-changing controls are exercised as round-trips (a column toggled off is
toggled back on, a pinned branch unpinned, the repository dropdown switched away and back), and
whatever an action opens — editor tabs (diff views, compare pages, opened files, the settings
page) and terminals — is closed again after the action, so the workspace is left as it was.
The runner re-opens the view if an action displaced the webview panel. The write suite
(real checkouts, merges, tags, stashes, …) runs when the target is safe to mutate — either a
fixture clone carrying the .gg-fixture marker (reset in place to its bare remote's state
first; the repository directory is never deleted), or a repository with no commits at all:
a fresh git init cannot hold real work, so the runner writes the full fixture history into it
in place (2000+ commits across 50 branches by default, a throwaway bare remote under the OS
temp dir recorded in the marker) and then treats it like any fixture clone. A repository that
already has commits and no marker is a real repository — only the read suite runs, nothing is
written to it. Build a fixture clone the scripted way with:
node scripts/automation/fixture.mjs --out /tmp/gg-fixture --commits 20000
# open /tmp/gg-fixture/fixture in VS Code, then press the buttonEach catalogued action runs one of three ways:
- UI mode — the real DOM path: the in-page shim (injected with the page,
out.min.jsuntouched) clicks the actual button / right-clicks the actual row / fills and confirms the actual dialog, so the timing includes webview rendering. Menu items are matched by their visible text with one candidate per shipped interface language (English and zh-CN), so the suite works under a localised interface too. The shim'sevalsteps compile expressions at runtime, so the automation build's webview CSP additionally allows'unsafe-eval'(the default build stays strict). - request mode — the same action's request message(s) injected straight into the extension host's message pipeline: same backend path a click takes, without rendering in the loop.
- command mode — the VS Code command a contributed menu item runs (the
menu-vscodegroup), executed throughvscode.commands.executeCommandwith the argument shape that menu passes: Show File History in Git Graph RS from all four surfaces that offer it (the Explorer / Editor / Editor tab context menus pass a file URI, the Source Control resource context menu passes aresourceUristate), the Source Control view's title and Pull/Push menu commands (which get the repository root), and the diff editor title's Open File button (a git-graph-rs diff-document URI). Native dialogs a command raises — the reset command's modal confirmation, the Gerrit amend prompt — are answered with their primary action, the same not-interactive policy as the data-loss warning; a file filter a command sets is cleared again by post-run page steps, and the tab a command opens is closed by the per-action cleanup.tests/contributedMenus.test.mjsstatically cross-checks the other half of the contract: every menu item points at a registered command, the language variants stay symmetric,whenclauses only use context keys the extension actually sets, and every menu command has one of these entries.
A run is verified: the expected host responses must all arrive (the runner auto-confirms the data-loss warning dialog exactly like a user would), and write actions additionally check the resulting repository state (HEAD moved, branch gone, ...).
Safety rails, because the suite can drive real writes:
- The write suite only runs against a repository it may mutate: a
.gg-fixturemarker clone (reset in place to its bare remote's state before every iteration, so write actions can run forever without accumulating damage — the directory itself is never deleted) or a repository with no commits (the fixture is generated into it first). Any other repository — one that already has commits — never sees a write action. - The in-place generation refuses a repository with even one commit anywhere, so a real project can never be imported over.
- The read suite never mutates the repository; its only lasting effects (webview state like the last scroll position) are the same a user's visit leaves behind.
tests/automationServer.test.mjs and tests/automationRunner.test.mjs drive the whole thing
in-process against the real compiled extension and webview in CI.
npm run test:automation:full -- --dir <path> runs the whole catalog — every read action,
then the write suite — against a real, freshly built git repository, outside CI: build (or
reuse) a fixture with 2000+ commits across 50 branches by default, boot the real compiled
extension and webview, drive every action, print a pass/fail/skip table, and write an HTML
report next to the fixture. Requires npm run compile first (it drives out/, not the
TypeScript sources).
--dir must be an empty or missing folder: the script builds the disposable fixture there
(a real git init + git clone, via fixture.mjs, not a mock) and must never modify an
existing project — especially not one that already has commits. A non-empty --dir prints a
reminder and exits instead of touching it. Re-running against the same --dir reuses the
fixture it already built there; --force builds inside a non-empty directory anyway, and should
only be used once you've checked what's actually there. See --help for the size knobs
(--commits, --branches, --tags, --authors, --seed) and --timeout.
Requires Rust 1.85+ and Node 18+. There is no node-gyp, no Python and no download of Node headers: napi-rs resolves the Node-API symbols at load time.
npm install
npm run build # the addon (debug) + the TypeScript
npm run build:native:release # the addon as it ships
npm test # cargo tests + the cross-backend integration tests
npm run bench -- <repo-path> # one view load, shipped backend vs git CLI
npm run bench:all -- <repo-path> # every read operation, one table row each (--json for machines)
npm run lint # clippy + rustfmtbuild-and-install.bat (Windows) and ./build-and-install.sh (macOS/Linux) run the whole
chain — addon, TypeScript, webview, tests — package the VSIX and install it into VS Code,
stopping at the first failure.
While git-graph-rs.enableLog is on, every spawned git command is logged with its duration and
every engine→CLI fallback with its reason; node scripts/analyze-log.mjs <logfile> summarises a
session log into where the time went and which methods fell back.
Cross-compiling goes through @napi-rs/cli:
node scripts/build-addon.mjs --release --target <triple> builds one target, build-rust.bat
builds the four common ones from a Windows host (--full adds Windows arm64 and macOS x64).
Foreign-OS targets use cargo-zigbuild (no Apple SDK needed); Windows arm64 links with rust-lld
against the Windows SDK. What the engine depends on is documented in
docs/DEPENDENCIES.md.
The engine is built for the six platforms VS Code itself ships for. The four common ones ship by
default; Windows arm64 and macOS x64 are built only by a full release run, and on them the
extension otherwise runs over the git CLI.
| Platform | Target | Ships by default |
|---|---|---|
| Windows x64 | x86_64-pc-windows-msvc |
✅ |
| Windows arm64 | aarch64-pc-windows-msvc |
full runs only |
| Linux x64 | x86_64-unknown-linux-gnu |
✅ |
| Linux arm64 | aarch64-unknown-linux-gnu |
✅ |
| macOS x64 | x86_64-apple-darwin |
full runs only |
| macOS arm64 | aarch64-apple-darwin |
✅ |
A platform whose binary is missing is still fully functional: the extension detects at load time
that no engine matches process.platform + process.arch and serves every query through the
git CLI, with an informational notice. The engine is a speed optimisation, never a requirement.
The reverse also holds: a machine without Git runs the whole read path on the engine alone, and
write operations report that they need Git.
native-build.yml builds one binary per platform on native
runners and assembles them into native/<platform>/git-graph.node, the layout the extension
loads from. release.yml is triggered by hand
(gh workflow run release.yml -f version=vX.Y.Z, add -f full=true for all six platforms); it
runs the whole pipeline and publishes a GitHub Release with two kinds of VSIX, nothing being
published unless every test passed:
- the universal VSIX (
git-graph-rs-<version>.vsix) — every built engine in one package,engines.vscode ^1.38.0; the engine is picked at load time; - the per-platform VSIXs (
git-graph-rs-<version>-<platform>.vsix) — one engine each, built withvsce package --targetand stamped^1.61.0, since only 1.61+ editors request platform-specific packages. Editors older than 1.61, and platforms with no package of their own, receive the universal VSIX as the Marketplace's documented fallback.
Publishing to the Marketplace is vsce publish --packagePath <file> per asset, the universal VSIX
first so the fallback exists, then each per-platform one.
The Rust engine (native/), the build scripts, the custom-made icons and the git-graph-rs-*
assets are original to this project and released under the MIT license.
The webview and extension host layers are ported and modified from
mhutchie/vscode-git-graph, whose license
(licenses/LICENSE_GIT_GRAPH) does not permit publishing derivative works — see LICENSE for how
that applies to this repository. Further credits: the Visual Studio Code Git Extension (Askpass,
Find Git Executable — MIT), Octicons, vscode-icons and Icons8 for icons, and the gix and
napi-rs ecosystems the engine builds on (MIT OR Apache-2.0). The full inventory lives
in licenses/THIRD-PARTY-NOTICES.md.
