I Chose the Git CLI for a VS Code Extension—Then Had to Own stdout

VS Code already ships with Git support. So when I started Minimal Git Explorer, the sensible shortcut was to ask the built-in Git extension for repository data.
I deliberately did not take it.
The built-in extension API is internal rather than an officially documented, versioned contract.
Minimal Git Explorer instead spawns the local git executable for every read.
That decision made the data path transparent and local-first. It also handed the extension a less glamorous responsibility: every byte of stdout became my problem.
Branches, commits, remotes, stashes, tags, and worktrees all arrive as strings. Those strings contain tabs, empty fields, optional records, user-authored text, and platform paths. The Git CLI is stable, but “stable” does not mean “already typed for TypeScript.”
The architecture only became pleasant after I separated two things that initially looked inseparable:
run Git -> receive stdout -> parse typed records -> render VS Code UI
The command runner owns processes. Pure parsers own text. The UI sees neither.
Round 1: Why Spawn Git at All?
The project's first architecture decision record lists four reasons for using the CLI directly.
The first is API stability. The VS Code Git extension exposes useful objects, but depending on an internal interface adds a compatibility surface outside the extension's control.
The second is local-first behavior. The extension's promise is to show the repository the developer already has, using the Git executable already installed. There is no second cache or hidden state model to reconcile.
The third is testability. Git output is plain text, which can be fed into a function without launching the VS Code Extension Host.
The fourth is transparency. Every repository read corresponds to a visible command and argument list. Someone reviewing the source can determine exactly what leaves the process boundary.
The wrapper is intentionally small:
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
Using execFile with an argument array matters.
The extension does not concatenate user-influenced values into a shell command, so it avoids creating a shell-injection surface merely to obtain Git data.
There are consequences. The extension must handle Git not being installed. It must own process latency and buffering. It must decide where stderr belongs. And, yes, it must parse stdout correctly.
Architectural freedom is often just choosing which chores you would rather understand.
Round 2: Do Not Parse Beside the Process Call
The first tempting shape is one function per feature:
async function getBranches() {
const stdout = await execGit([...]);
return stdout.split("\n").map(...);
}
It looks compact. It also mixes two failure domains. A process may fail because Git is missing, the repository is invalid, or the command rejects. A parser may fail because delimiters, empty fields, or record shapes differ from the assumption.
Minimal Git Explorer keeps parsing in src/git/parsers.ts as pure functions with no VS Code import and no process calls.
The service layer composes them with execGit.
That creates a clean test seam:
export function parseTags(stdout: string): GitTag[] {
if (!stdout.trim()) {
return [];
}
return stdout
.trim()
.split("\n")
.filter((line) => line.trim() !== "")
.map((name) => ({ name: name.trim() }));
}
The real parsers are not all that simple, of course. Git has heard about edge cases and would like us to meet every one of them personally.
Round 3: Whitespace Is Sometimes Data
The local-branch parser contains one of my favorite small warnings: do not trim the entire stdout string before splitting it.
The command format includes Git's %(HEAD) field.
For the current branch, that field contains *.
For other branches, it contains a literal space.
If the parser trims the whole output before it establishes record boundaries, the last record can lose the empty marker along with surrounding tabs. The parser then sees a different field shape from the one Git emitted.
So parseLocalBranches() splits first, filters blank records, then trims individual fields.
This is a general CLI lesson: whitespace can be presentation, separation, or data depending on where you are in the grammar. Global cleanup before parsing destroys the evidence needed to tell those roles apart.
The commit parser uses another defensive pattern.
It searches for each expected tab and drops malformed lines rather than indexing blindly into a short array.
The stash parser recognizes the On <branch>: and WIP on <branch>: forms but retains an unknown fallback for other valid-looking messages.
The worktree parser handles block records with optional branch, detached, and bare lines.
None of this code is intellectually fashionable. That is precisely why pure fixture tests are valuable. The parser can be boring in public and paranoid in private.
Round 4: Make the CLI Output Easier to Parse
The best parser improvement often starts in the command arguments.
Instead of parsing Git's human-oriented decoration, an extension can request explicit delimiters and fields with --format or for-each-ref placeholders.
The command becomes an internal wire protocol between Git and the application.
That protocol should be designed like any other:
- choose delimiters that are valid to split at the intended level;
- include every field the UI needs and no more;
- preserve full identifiers alongside display identifiers;
- keep user-authored free text in the final field when possible;
- define behavior for empty output and malformed records;
- test captured output as fixtures.
Git is still the source of truth. The application is merely asking it to speak a dialect that is less likely to be misunderstood.
Round 5: Reads and Mutations Need Different Trust
The CLI-only data path makes reads explicit, but the same wrapper can also run mutations. That does not mean every command deserves the same UI treatment.
At the recorded HEAD, Minimal Git Explorer's checkoutBranch and applyStash commands check whether the relevant working tree is dirty.
When it is, they show a modal warning before continuing.
If the user declines, the command returns before invoking Git.
The condition is important. The current implementation does not display that warning for a clean tree, and the article does not pretend it has implemented a universal confirmation framework for every future destructive command.
What the design establishes is the boundary: repository reads can refresh quietly; operations that may overwrite or entangle uncommitted work require an explicit state check and user decision.
This is easier to enforce because Git access already passes through known layers.
The command layer can ask isDirty(), own the modal, and call the service only after consent.
What I Would Carry Into Another CLI-Backed App
This pattern is not limited to Git or VS Code. Many useful tools wrap a mature CLI: package managers, cloud clients, media processors, language servers, and infrastructure commands.
The reusable architecture is small:
- Spawn the executable without an intermediate shell when possible.
- Keep command arguments explicit and reviewable.
- Treat stdout as a protocol, not as a blob of convenient text.
- Put parsing in pure, framework-free functions.
- Make malformed and empty output deliberate cases.
- Keep stderr and exit status in the effectful service layer.
- Put state checks and confirmation gates in front of mutations.
The CLI gives the application leverage because it already knows the domain. The pure parser gives the application confidence because it makes the boundary testable.
I chose the Git CLI to avoid depending on a hidden API. That did not remove complexity. It moved complexity into text I could see, functions I could test, and commands I could audit.
I will take that trade.
Even if it means caring deeply about one trailing space in %(HEAD).
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.